191 KiB
191 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""전투원 웨어러블 더미 생체신호(심박수, 체온, 피부전도도)와 가상 병사 특성·기상·기기 상태의 데이터 스키마를 정의하고, 가상 샘플 20건을 검증한다. 진단 목적이 아닌 교육용 가상 자료이다."""
import json
import re
import numpy as np
import pandas as pd
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# 1) 스키마 정의: 신호 규격, 가상 병사 특성, 기상(온·습도·풍속), 기기 상태
SCHEMA = {
"required": ["record_id", "soldier_id", "n_samples", "sampling_s", "device", "person", "weather", "samples"],
"fields": {
"record_id": {"type": str, "pattern": r"^REC-\d{4}$"},
"soldier_id": {"type": str, "pattern": r"^S\d{3}$"},
"n_samples": {"type": int, "min": 1, "max": 100000},
"sampling_s": {"type": (int, float), "min": 1, "max": 600},
},
"device": {
"type": {"type": str, "enum": ["wrist", "chest"]},
"battery_pct": {"type": (int, float), "min": 0, "max": 100},
"contact_quality": {"type": (int, float), "min": 0, "max": 1},
},
"person": {
"rest_hr": {"type": (int, float), "min": 40, "max": 100},
"age": {"type": int, "min": 18, "max": 45},
"bmi": {"type": (int, float), "min": 15, "max": 40},
"acclim": {"type": int, "min": 0, "max": 1},
},
"weather": {
"temp_c": {"type": (int, float), "min": -40, "max": 55},
"rh": {"type": (int, float), "min": 0, "max": 100},
"wind": {"type": (int, float), "min": 0, "max": 30},
},
"sample": {
"hr": {"type": (int, float), "min": 30, "max": 220},
"temp": {"type": (int, float), "min": 30, "max": 43},
"eda": {"type": (int, float), "min": 0, "max": 40},
},
}
def check(name, value, rule):
"""값 하나를 규칙(type, enum, min, max, pattern)으로 검사해 오류 목록을 돌려준다."""
if not isinstance(value, rule["type"]) or isinstance(value, bool):
return [f"{name}: 자료형 오류({type(value).__name__})"]
errs = []
if "enum" in rule and value not in rule["enum"]:
errs.append(f"{name}: 허용되지 않은 값 '{value}'")
if "min" in rule and value < rule["min"]:
errs.append(f"{name}: 최소값 {rule['min']} 미만")
if "max" in rule and value > rule["max"]:
errs.append(f"{name}: 최대값 {rule['max']} 초과")
if "pattern" in rule and not re.match(rule["pattern"], value):
errs.append(f"{name}: 형식 불일치 '{value}'")
return errs
def validate(rec):
errs = [f"{k}: 필수 필드 누락" for k in SCHEMA["required"] if k not in rec]
for k, rule in SCHEMA["fields"].items():
if k in rec:
errs += check(k, rec[k], rule)
for group in ("device", "person", "weather"):
for k, rule in SCHEMA[group].items():
if k not in rec.get(group, {}):
errs.append(f"{group}.{k}: 필수 필드 누락")
else:
errs += check(f"{group}.{k}", rec[group][k], rule)
for i, smp in enumerate(rec.get("samples", [])):
for k, rule in SCHEMA["sample"].items():
if k not in smp:
errs.append(f"samples[{i}].{k}: 필수 필드 누락")
else:
errs += check(f"samples[{i}].{k}", smp[k], rule)
if "n_samples" in rec and rec["n_samples"] != len(rec.get("samples", [])):
errs.append("n_samples: 샘플 개수와 불일치")
return errs
# 2) [교육용가상자료] 가상 병사 20명의 더미 세션에서 샘플 레코드 생성(센서 오류 없는 정상 데이터)
rng = np.random.default_rng(1)
records = []
for i in range(20):
s = make_session(i, TYPES[i % 3], rng, glitch=False)
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()]
p, e = s["person"], s["env"]
records.append({"record_id": f"REC-{i:04d}", "soldier_id": s["id"], "n_samples": len(smp), "sampling_s": 60,
"device": {"type": str(rng.choice(["wrist", "chest"])), "battery_pct": int(rng.integers(30, 100)), "contact_quality": round(float(rng.uniform(0.7, 1)), 2)},
"person": {"rest_hr": round(p["rest_hr"], 1), "age": p["age"], "bmi": round(p["bmi"], 1), "acclim": p["acclim"]},
"weather": {k: round(v, 1) for k, v in e.items()}, "samples": smp})
# 3) 정상 데이터 검증
ok = sum(1 for r in records if not validate(r))
print(f"정상 샘플 {len(records)}건 중 검증 통과 {ok}건, 오류 {len(records) - ok}건 -> 목표(검증 오류 0건): {'달성' if ok == len(records) else '미달'}")
# 4) 오류 주입 시험
bad = json.loads(json.dumps(records[0]))
bad["soldier_id"] = "SOLDIER-1" # 형식
bad["samples"][0]["temp"] = 45.0 # 범위(센서 튐)
del bad["weather"]["wind"] # 누락(풍속)
bad["device"]["type"] = "ankle" # 허용값
bad["n_samples"] = 99 # 논리(개수 불일치)
errs = validate(bad)
print(f"\n[오류 주입 결과] 주입 5종 -> 검출 {len(errs)}건")
for e in errs:
print(" -", e)
print(f"주입 오류 전부 검출: {'예' if len(errs) >= 5 else '아니오'}")
# 5) 스키마 필드 정의서(명세서 초안)
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()]
for g in ("device", "person", "weather", "sample"):
for k, r in SCHEMA[g].items():
t = r["type"].__name__ if isinstance(r["type"], type) else "number"
rows.append((g, k, t, str({x: y for x, y in r.items() if x != "type"})))
print("\n[데이터 명세 요약] 필드 수", len(rows))
print(pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙"]).to_string(index=False))
print("\n상태 단계(교육용 가상 라벨):", STAGE, "/ 위험 유형:", TYPES)
print("본 데이터는 코드로 만든 더미 생체신호이며 진단·처방 자료가 아니다. 실제 건강 이상은 의무대·군의관에게 연계한다.")
In [ ]:
"""야전 활동 중 생체신호의 결측·센서 튐·고착(센서 단절)을 탐지·보정하는 전처리 모듈을 구현하고, 처리 속도·단위 테스트 5종·보정 전후 체온 오차와 경보 분류 성능을 측정한다. 진단 목적이 아닌 교육용 가상 자료이다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
def flatline(series, run=8):
"""같은 값이 run분 이상 이어지면 센서 고착(단절)으로 보고 해당 구간을 True로 표시한다."""
same = series.diff().abs().fillna(1) < 1e-9
grp = (~same).cumsum()
return same.groupby(grp).transform("sum").add(1).where(same | same.shift(-1, fill_value=False), 1) >= run
def preprocess(df):
"""고착 구간을 결측으로 바꾼 뒤 clean()으로 범위 검사·보간·튐 제거. 반환: (정제 df, 보정 건수, 고착 건수)"""
d = df.copy(); stuck = 0
for c in ("hr", "temp"):
fl = flatline(d[c].round(3))
stuck += int(fl.sum()); d.loc[fl, c] = np.nan
out, fixed = clean(d)
return out, fixed, stuck
def inject_faults(df, rng):
"""센서 단절 모의: 체온이 가장 크게 변하는 시점 직전부터 센서가 12분간 같은 값으로 고착된다(실제 변화를 따라가지 못함)."""
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))
d.loc[s:s + 11, "temp"] = d.loc[s, "temp"]
return d
rng = np.random.default_rng(5)
data = make_dataset(120, seed=21)
# 1) 처리 속도와 오류율
t0 = time.perf_counter(); n_rows = 0; errors = 0; fixed_all = 0
for s in data:
try:
out, fx, st = preprocess(s["df"]); n_rows += len(out) * 3; fixed_all += fx
errors += int(out[["hr", "temp", "eda"]].isna().any().any() or (out["temp"] > 43).any())
except Exception:
errors += 1
sec = time.perf_counter() - t0
print(f"전처리 {n_rows:,}건(신호값) / {sec:.2f}초 = {n_rows / sec:,.0f}건/초 (목표 1,000 이상: {'달성' if n_rows / sec >= 1000 else '미달'})")
print(f"전처리 오류 {errors}건 -> 오류율 {errors / len(data):.0%} (목표 0%), 범위 밖·결측 보정 {fixed_all}건")
# 2) 단위 테스트 5종
def t1():
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"})
out, fx = clean(d); assert out["hr"].notna().all() and fx == 1
def t2():
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"})
out, fx = clean(d); assert out["hr"].max() < 100 and fx == 1
def t3():
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
def t4():
s = pd.Series(np.linspace(36.5, 37.5, 20)); assert not flatline(s).any()
def t5():
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
tests = {"결측 보간": t1, "센서 튐 제거": t2, "고착 구간 탐지": t3, "정상 변화 오탐 없음": t4, "고착 주입 후 보정": t5}
passed = 0
for name, fn in tests.items():
try:
fn(); passed += 1; print(f"[PASS] {name}")
except AssertionError:
print(f"[FAIL] {name}")
print(f"단위 테스트 {passed}/5 통과")
# 3) 보정 전후 체온 오차(정답 core 대비 RMSE)와 경보 분류 성능
def rmse(a, b):
m = ~np.isnan(a)
return float(np.sqrt(np.mean((a[m] - b[m]) ** 2)))
def hot_cold_f1(df_use, stage_src):
"""체온 38.0 이상 또는 36.3 이하를 위험으로 본 경보와 정답 stage>=1의 F1."""
pred = (df_use["temp"] >= 38.0) | (df_use["temp"] <= 36.3)
truth = stage_src["stage"] >= 1
tp = (pred & truth).sum(); p = tp / max(pred.sum(), 1); r = tp / max(truth.sum(), 1)
return round(2 * p * r / max(p + r, 1e-9), 3), int((pred & ~truth).sum())
rows = []
for kind, label in (("normal", "평시"), ("heat", "폭염"), ("cold", "혹한")):
raw, cln, tr_ = [], [], []
for s in [x for x in data if x["kind"] == kind]:
bad = inject_faults(s["df"], rng)
out, _, _ = preprocess(bad)
raw.append(bad); cln.append(out); tr_.append(s["df"])
raw, cln, tr_ = pd.concat(raw), pd.concat(cln), pd.concat(tr_)
f_raw, fp_raw = hot_cold_f1(raw, tr_)
f_cln, fp_cln = hot_cold_f1(cln, tr_)
rows.append({"상황": label, "체온 RMSE 전": round(rmse(raw["temp"].where(raw["temp"] <= 43).values, tr_["core"].values), 3),
"체온 RMSE 후": round(rmse(cln["temp"].values, tr_["core"].values), 3), "경보 F1 전": f_raw, "경보 F1 후": f_cln, "오경보 전": fp_raw, "오경보 후": fp_cln})
print("\n[보정 전후 비교] (전: 센서 튐·결측·고착 그대로 / 후: 전처리 적용)")
print(pd.DataFrame(rows).to_string(index=False))
print("전술 노이즈 대응: 체온 센서 고착(단절)은 8분 이상 같은 값이면 결측 처리 후 보간하고, 범위 밖 값은 센서 오류로 본다. 스푸핑 판별은 본 과제 범위 밖이다.")
s = data[1]; bad = inject_faults(s["df"], rng); out, _, _ = preprocess(bad)
plt.figure(figsize=(9, 4))
plt.plot(bad["min"], bad["temp"], ".", color="tab:red", label="raw (faults)"); plt.plot(out["min"], out["temp"], color="tab:blue", label="cleaned")
plt.plot(s["df"]["min"], s["df"]["core"], "k:", label="true (simulated)"); plt.ylim(34, 41); plt.xlabel("minute"); plt.ylabel("temp (C)")
plt.title(f"{s['id']} temperature before/after preprocessing (synthetic)"); plt.legend(); plt.show()
In [ ]:
"""체온과 심박수의 상관관계(체온 상승 시 심박수 동반 상승)를 이용한 규칙 기반 1차 온열·한랭 스트레스 경보 Baseline을 구현하고, 가상 병사 데이터에서 정확도와 위험 구간 재현율을 측정한다. 진단 아님·의무대 연계 원칙을 따른다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
# 판단 기준(투명성): ① 최근 5분 평균 체온이 38.0도 이상이고 심박수가 95 이상이면 '온열 스트레스 의심'
# ② 최근 5분 평균 체온이 36.3도 이하이면 '한랭 스트레스 의심' ③ 그 외 정상
TH_HEAT, TH_COLD, HR_GATE = 38.0, 36.3, 95
def rule(r, th=TH_HEAT, tc=TH_COLD, hg=HR_GATE):
if r["temp5"] >= th and r["hr5"] >= hg:
return "heat"
if r["temp5"] <= tc:
return "cold"
return "normal"
data = make_dataset(90, seed=7)
tab = table(data, use_env=False, personal=False)
tab["pred"] = tab.apply(rule, axis=1)
acc = (tab["pred"] == tab["rtype"]).mean()
print(f"[Baseline] 가상 병사 90명 x 55분 = {len(tab):,}개 구간, 정답 분포 {tab['rtype'].value_counts().to_dict()}")
print(f"경보 유형 분류 정확도 {acc:.1%} (목표 75% 이상) -> {'달성' if acc >= 0.75 else '미달'}")
print("\n[유형별 판정] (행: 정답, 열: 판정)")
cm = pd.crosstab(tab["rtype"], tab["pred"]).reindex(index=TYPES, columns=TYPES, fill_value=0)
print(cm.to_string())
for c in ("heat", "cold"):
rec = cm.loc[c, c] / cm.loc[c].sum(); prec = cm.loc[c, c] / max(cm[c].sum(), 1)
print(f"{c}: 재현율 {rec:.1%}, 정밀도 {prec:.1%}")
print("\n[단계별 위험 구간 재현율] (위험 유형을 위험으로 맞춘 비율)")
risk = tab[tab["stage"] >= 1]
print(risk.groupby("stage").apply(lambda g: f"{(g['pred'] == g['rtype']).mean():.1%} ({len(g)}구간)").to_string())
print("\n[체온-심박수 상관] 전체 상관계수 %.2f / 폭염 세션 %.2f / 혹한 세션 %.2f" % (
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]))
print("\n[임계값 민감도] 온열 체온 기준 변화")
for th in (37.7, 38.0, 38.3, 38.6):
p = tab.apply(lambda r: rule(r, th=th), axis=1)
r = ((p == "heat") & (tab["rtype"] == "heat")).sum() / (tab["rtype"] == "heat").sum(); f = ((p == "heat") & (tab["rtype"] != "heat")).sum()
print(f"기준 {th}: 온열 재현율 {r:.1%}, 오경보 {f}구간")
print("한계: 개인별 안정시 심박·기상·활동 강도를 반영하지 않아 운동 직후 심박 상승과 바람에 의한 센서 냉각을 구분하지 못한다 -> 미션 4·5에서 보완한다.")
print("본 결과는 교육용 가상 자료의 경보 단계이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.")
colr = {"normal": "tab:gray", "heat": "tab:red", "cold": "tab:blue"}
plt.figure(figsize=(7, 5))
for k, g in tab.groupby("rtype"):
plt.scatter(g["temp5"], g["hr5"], s=6, alpha=0.4, color=colr[k], label=k)
plt.axvline(TH_HEAT, ls="--", color="k"); plt.axvline(TH_COLD, ls="--", color="k"); plt.axhline(HR_GATE, ls=":", color="k")
plt.xlabel("temperature (5-min mean, C)"); plt.ylabel("heart rate (5-min mean, bpm)"); plt.legend(); plt.title("Baseline rule on dummy signals")
plt.show()
In [ ]:
"""체온·심박수·피부전도도에 기상(온도·습도·풍속)과 개인 특성(안정시 심박, 순응 여부, 체격)을 결합한 복합 환경 위험도 분류 모델(그래디언트 부스팅)로 고도화하고, 위험 구간 재현율·처리 지연을 Baseline과 비교한다. 센서 이상 예외 탐지 10종도 시험한다. 진단 아님·의무대 연계."""
import time
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
ADV_FEATS = ["temp5", "hr5", "eda5", "dtemp", "act", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"]
LABEL = {"normal": 0, "heat": 1, "cold": 2}
def baseline_pred(t):
return np.where((t["temp5"] >= 38.0) & (t["hr5"] >= 95), "heat", np.where(t["temp5"] <= 36.3, "cold", "normal"))
train = table(make_dataset(150, seed=31))
clf = GradientBoostingClassifier(n_estimators=80, max_depth=3, learning_rate=0.1, random_state=0).fit(train[ADV_FEATS], train["rtype"].map(LABEL))
inv = {v: k for k, v in LABEL.items()}
# 시험셋 2종: 일반 / 복합 교란(강풍 혹한 + 고강도 활동 + 센서 튐 많음)
std = make_dataset(90, seed=77)
rng = np.random.default_rng(9)
hard = []
for i in range(60):
s = make_session(i, ["heat", "cold"][i % 2], rng)
if s["kind"] == "cold":
s["env"]["wind"] = float(rng.uniform(7, 11))
for col, val in (("hr", 230.0), ("temp", 45.0)):
idx = rng.choice(np.arange(5, N_MIN), 3, replace=False); s["df"].loc[idx, col] = val
hard.append(s)
rows = []
for name, sess in (("일반", std), ("복합 교란", hard)):
t = table(sess)
pb = baseline_pred(t)
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)
risk = t["stage"] >= 1; danger = t["stage"] == 2
rows.append({"시험셋": name, "구간 수": len(t), "Baseline 위험 재현율": round(float((pb[risk] == t["rtype"][risk]).mean()), 3),
"고도화 위험 재현율": round(float((pa[risk] == t["rtype"][risk]).mean()), 3),
"고도화 경보(2단계) 재현율": round(float((pa[danger] == t["rtype"][danger]).mean()), 3),
"Baseline 오경보": int(((pb != "normal") & ~risk).sum()), "고도화 오경보": int(((pa != "normal") & ~risk).sum()), "지연(ms/구간)": round(ms, 3)})
out = pd.DataFrame(rows)
print(out.T.to_string(header=False))
rec = out["고도화 위험 재현율"].min(); lat = out["지연(ms/구간)"].max()
imp = pd.Series(clf.feature_importances_, index=ADV_FEATS).sort_values(ascending=False)
print("\n특징 중요도 상위:", ", ".join(f"{k} {v:.2f}" for k, v in imp.head(5).items()))
print(f"\n복합 위협 탐지 재현율(최저 시험셋) {rec:.1%} (목표 85% 이상) -> {'달성' if rec >= 0.85 else '미달'}")
print(f"처리 지연 최대 {lat:.3f} ms/구간 (목표 200 ms 이하) -> {'달성' if lat <= 200 else '미달'}")
# ---- 예외(센서 이상·기만) 탐지 10종: 모델에 넣기 전 신호 자체의 신뢰성을 점검한다 ----
def anomaly(w):
"""최근 10분 원시 신호 창(hr, temp, eda 배열 dict)의 이상 유형 목록"""
flags = []
for c in ("hr", "temp", "eda"):
v = w[c]
if np.isnan(v).all():
flags.append(f"{c}_missing_all")
else:
lo, hi = LIMITS[c]
if ((v < lo) | (v > hi)).any():
flags.append(f"{c}_out_of_range")
if np.isnan(v).mean() > 0.3:
flags.append(f"{c}_many_missing")
ok = v[~np.isnan(v)]
if len(ok) >= 8 and np.ptp(ok) < 1e-6:
flags.append(f"{c}_flatline")
if len(ok) >= 3 and np.abs(np.diff(ok)).max() > {"hr": 60, "temp": 1.5, "eda": 10}[c]:
flags.append(f"{c}_jump")
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():
if np.nanmean(w["temp"]) >= 38.5 and np.nanmean(w["hr"]) < 60:
flags.append("temp_hr_inconsistent")
return flags
def win(hr=80, temp=37.0, eda=2.0, n=10):
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}
cases = []
w = win(); cases.append(("1 정상", w, []))
w = win(); w["hr"][4] = 230; cases.append(("2 심박 튐", w, ["hr_out_of_range", "hr_jump"]))
w = win(); w["temp"][:] = 37.0; cases.append(("3 체온 고착", w, ["temp_flatline"]))
w = win(); w["temp"][:6] = np.nan; cases.append(("4 체온 결측 다수", w, ["temp_many_missing"]))
w = win(); w["hr"][:] = np.nan; cases.append(("5 심박 전체 두절", w, ["hr_missing_all"]))
w = win(); w["temp"][3] = 45.0; cases.append(("6 체온 범위 이탈", w, ["temp_out_of_range", "temp_jump"]))
w = win(temp=39.0, hr=55); cases.append(("7 체온-심박 불일치", w, ["temp_hr_inconsistent"]))
w = win(); w["eda"][:] = 2.0; cases.append(("8 피부전도 고착", w, ["eda_flatline"]))
w = win(); w["temp"][5:] += 2.0; cases.append(("9 체온 급변", w, ["temp_jump"]))
w = win(); w["hr"][:] = 80.0; w["temp"][:] = 37.0; cases.append(("10 심박·체온 동시 고착", w, ["hr_flatline", "temp_flatline"]))
hit = 0
print("\n[센서 이상 탐지 시험 10종]")
for name, w, expect in cases:
got = anomaly(w); good = all(e in got for e in expect) and (bool(got) == bool(expect)); hit += good
print(f"[{'PASS' if good else 'FAIL'}] {name}: {', '.join(got) or '이상 없음'}")
print(f"통과 {hit}/10. 이상 플래그가 있으면 모델 경보보다 먼저 '센서 점검 필요'를 표시하고, 판단은 소대장·의무관 확인으로 넘긴다(기만·교란 신호로 인한 오판 방지).")
print("본 결과는 교육용 가상 자료 기준이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.")
In [ ]:
"""개인별 안정시 심박·체격·순응 여부를 반영해 활동 강도로 설명되는 심박 상승을 보정한 개인화 위험 지수를 계산하고, 오경보를 줄이는 신뢰도 점수와 등급(High/Medium/Low)을 산출해 고정 임계값 방식 대비 오경보율(FAR) 감축과 점수 일관성을 검증한다. 진단 아님·의무대 연계."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
PF = ["temp5", "dtemp", "hr_excess", "eda5", "hi", "wc", "acclim", "bmi", "tdev"]
def personalize(t):
"""활동 강도·개인 체력으로 예상되는 심박을 빼 '설명되지 않는 심박 상승(hr_excess)'과 체온 편차(tdev)를 만든다."""
t = t.copy()
expected = t["hr5"] - t["hr_rel"] + 9.5 * (t["act"].rolling(5, min_periods=1).mean() - 1.5) * (1.15 - 0.3 * t["fit"])
t["hr_excess"] = t["hr5"] - expected
t["tdev"] = (t["temp5"] - 36.9).abs()
return t
def grade(p):
return "High" if p >= 0.8 else ("Medium" if p >= 0.5 else "Low")
train = personalize(table(make_dataset(180, seed=41)))
test = personalize(table(make_dataset(90, seed=43)))
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", C=1.0, max_iter=3000, random_state=0))
model.fit(train[PF], train["stage"] >= 1)
test["score"] = model.predict_proba(test[PF])[:, 1]
test["grade"] = test["score"].map(grade)
true = (test["stage"] >= 1).values
fixed = ((test["temp5"] >= 37.8) | (test["temp5"] <= 36.5) | (test["hr5"] >= 130)).values # 기존: 고정 임계값 경보
eng = (test["score"] >= 0.5).values
def far_rec(alert):
return (alert & ~true).sum() / max(alert.sum(), 1), (alert & true).sum() / true.sum()
f0, r0 = far_rec(fixed); f1, r1 = far_rec(eng)
print(pd.DataFrame([{"방식": "고정 임계값(체온 37.8 이상·36.5 이하·심박 130 이상)", "경보 구간": int(fixed.sum()), "오경보율": round(f0, 3), "위험 재현율": round(r0, 3)},
{"방식": "개인화 신뢰도 엔진(0.5 이상)", "경보 구간": int(eng.sum()), "오경보율": round(f1, 3), "위험 재현율": round(r1, 3)}]).to_string(index=False))
cut = 1 - f1 / f0
print(f"\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'}), ROC-AUC {roc_auc_score(true, test['score']):.3f}")
# 점수 일관성: 센서 값에 +-2% 측정 흔들림을 주어도 같은 등급이 유지되는 비율
rng = np.random.default_rng(0)
X = test[PF].values; g0 = test["grade"].values
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)]
print(f"신뢰도 등급 일관성 {np.mean(same):.1%} (같은 입력 재실행 100%, 목표 90% 이상: {'달성' if np.mean(same) >= 0.9 else '미달'})")
print("\n[단계별 등급 분포] (행: 정답 단계)")
print(pd.crosstab(test["stage"].map(STAGE).rename("정답 단계"), test["grade"].rename("등급")).to_string())
# 개인화 효과: 같은 심박·체온이라도 체력·순응 여부에 따라 지수가 달라지는 예
ex = test.iloc[[0]].copy()
rows = []
for name, fit, acc in (("체력 낮음·비순응", 0.2, 0), ("체력 높음·순응", 0.8, 1)):
e = ex.copy(); e["fit"], e["acclim"] = fit, acc
e["hr_rel"] = e["hr5"] - 64; e["hr_excess"] = 0; e = personalize(e); e["act"] = 4.5
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)
rows.append({"가상 병사": name, "초과 심박": round(float(e["hr_excess"].iloc[0]), 1), "위험 점수": round(float(model.predict_proba(e[PF])[:, 1][0]), 3)})
print("\n[개인화 예시: 같은 심박 128·체온 38.3]")
print(pd.DataFrame(rows).to_string(index=False))
print("같은 측정값이라도 체력·더위 순응 여부에 따라 위험 점수가 달라진다(순응 병사는 같은 체온에서 점수가 낮아짐).")
sc, lr = model[0], model[-1]
i = int(np.argmax(test["score"].values)); c = (X[i] - sc.mean_) / sc.scale_ * lr.coef_[0]
print(f"\n최고 점수 구간 근거: ", ", ".join(f"{PF[j]}({c[j]:+.2f})" for j in np.argsort(-np.abs(c))[:3]))
print("등급 기준: High(0.8 이상)는 즉시 소대장 확인·의무대 연락 검토, Medium(0.5~0.8)은 재측정·휴식 확인, Low(0.5 미만)는 모니터링 유지. 점수는 확인 순서를 정하는 보조 정보이며 진단이 아니다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
for st, col in ((0, "tab:gray"), (1, "tab:orange"), (2, "tab:red")):
ax[0].hist(test.loc[test["stage"] == st, "score"], bins=10, range=(0, 1), alpha=0.6, color=col, label=f"stage {st}")
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")
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")
plt.tight_layout(); plt.show()
In [ ]:
"""소대장·의무관용 가상 소대 24명의 생체 위험 신호 모니터링 상황판을 구현한다. 병사별 상태(정상·주의·경보·신호두절), 체온·심박 추세, 조치 권고 문구를 1초 주기로 갱신하고 처리시간을 측정한다. 진단 아님·의무대 연계."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"]
tr = table(make_dataset(120, seed=51))
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1)
# [교육용가상자료] 가상 소대 24명(가명 ID): 폭염 8, 혹한 8, 평시 8 구역에서 활동 중인 병사들의 화면
rng = np.random.default_rng(3)
kinds = ["heat"] * 8 + ["cold"] * 8 + ["normal"] * 8
squad = [make_session(i, k, rng) for i, k in enumerate(kinds)]
for s in squad[:2]:
s["df"].loc[40:, ["hr", "temp", "eda"]] = np.nan # 신호 두절 2명(기기 이탈·배터리 방전 모의)
FEAT = {s["id"]: features(s) for s in squad} # 병사별 분 단위 특징표(재생용)
RAW = {s["id"]: s["df"] for s in squad}
def snapshot(minute):
"""분 단위 1틱: 병사별 상태·점수·권고를 계산해 상황판 표를 만든다."""
rows = []
for s in squad:
f = FEAT[s["id"]]; r = f[f["min"] == minute]
raw = RAW[s["id"]].loc[max(minute - 4, 0):minute, ["hr", "temp"]]
if raw.isna().all().all():
rows.append({"병사": s["id"], "상태": "신호두절", "점수": np.nan, "체온": np.nan, "심박": np.nan, "추세": "-", "권고": "기기 착용·배터리 확인, 소대장 대면 확인"}); continue
sc = float(model.predict_proba(r[FE])[:, 1][0])
st = "경보" if sc >= 0.8 else ("주의" if sc >= 0.5 else "정상")
rec = {"경보": "소대장 즉시 확인, 의무대 연락 검토", "주의": "재측정·휴식 상태 확인", "정상": "-"}[st]
rows.append({"병사": s["id"], "상태": st, "점수": round(sc, 2), "체온": round(float(r["temp5"].iloc[0]), 1), "심박": int(r["hr5"].iloc[0]),
"추세": f"{float(r['dtemp'].iloc[0]):+.1f}", "권고": rec})
return pd.DataFrame(rows)
# 1) 1초 주기 갱신 모의: 5틱 갱신 시간 측정
times = []
for m in range(45, 50):
t0 = time.perf_counter(); board = snapshot(m); times.append((time.perf_counter() - t0) * 1000)
print(f"상황판 갱신 처리시간 평균 {np.mean(times):.1f} ms, 최대 {max(times):.1f} ms (목표: 갱신 주기 1초 이내 -> {'달성' if max(times) < 1000 else '미달'})")
# 2) 병사 24명 -> 확인 대상 요약(인지 부하 감소)
order = {"신호두절": 0, "경보": 1, "주의": 2, "정상": 3}
board = board.sort_values(["상태", "점수"], key=lambda s: s.map(order) if s.name == "상태" else -s.fillna(2), kind="stable")
cnt = board["상태"].value_counts().reindex(["경보", "주의", "신호두절", "정상"]).fillna(0).astype(int)
print(f"\n[소대 현황] 병사 {len(board)}명 -> 확인 대상 {len(board) - cnt['정상']}명으로 압축:", cnt.to_dict())
print("\n[우선 확인 목록: 신호두절 > 경보 > 주의 순]")
print(board[board["상태"] != "정상"].head(12).fillna("-").to_string(index=False))
# 3) 렌더링 시간(UI 응답성)과 상황판 그림
t0 = time.perf_counter()
col = {"경보": "tab:red", "주의": "orange", "정상": "tab:green", "신호두절": "dimgray"}; EN = {"경보": "ALERT", "주의": "CAUTION", "정상": "NORMAL", "신호두절": "NO SIGNAL"}
fig, ax = plt.subplots(2, 3, figsize=(13, 6))
focus = board[board["상태"].isin(["경보", "주의"])].head(5)["병사"].tolist()
for a, sid in zip(ax.ravel()[:5], focus):
d = RAW[sid]; st = board[board["병사"] == sid]["상태"].iloc[0]
a.plot(d["min"], d["temp"].interpolate(), color=col[st]); a.axvline(49, color="k", ls=":"); a.set_ylim(34, 41)
a.set_title(f"{sid} [{EN[st]}] temperature trend", fontsize=9); a.set_xlabel("minute")
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")
plt.suptitle("Soldier bio-signal board (dummy data, not diagnosis)"); plt.tight_layout(); fig.canvas.draw()
render = (time.perf_counter() - t0) * 1000
print(f"\n한 화면 렌더링 {render:.0f} ms (UI 응답 목표 100 ms: 데이터 갱신 {np.mean(times):.1f} ms는 달성, 렌더링은 실행 환경에 따라 달라짐)")
print("상황판의 상태와 권고는 소대장·의무관의 확인을 돕는 보조 정보이며 진단이 아니다. 의학적 판단과 조치는 군의관·의무대가 하며, 조치 명령은 미션 7의 HITL 절차를 따른다.")
plt.show()
In [ ]:
"""위험 신호가 발생한 가상 병사에 대해 군의관이 원격 검토하고 휴식 권고·임무 배제 요청·의무대 인계 요청·재측정 보류를 결정하는 인간 개입(HITL) 절차를 구현한다. AI는 어떤 조치도 자동으로 명령하지 않으며 모든 결정은 담당자 ID·역할·근거와 함께 위·변조 확인이 가능한 감사 로그에 기록된다. 진단 아님."""
import hashlib
import json
import pandas as pd
ACTIONS = {"REST": "휴식 권고(현장 확인 후 이행)", "EXCLUDE": "임무 배제 요청(지휘관 승인 대상)", "REFER": "의무대 인계 요청", "HOLD": "재측정·보류"}
ROLES = {"platoon_leader": "소대장", "medical_officer": "군의관", "commander": "지휘관"}
PRIORITY = {"High": 0, "Medium": 1, "Low": 2}
HOLD_LIMIT_S = 60
class WorkflowError(ValueError):
pass
class ReviewQueue:
def __init__(self, clock):
self.items, self.log, self.clock = {}, [], clock
# ---- 감사 로그: 직전 해시를 포함한 해시 체인(추가 전용) ----
def _record(self, body):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {**body, "ts": round(self.clock(), 3)}
h = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()
self.log.append({**body, "prev": prev, "hash": h})
def verify_log(self):
prev = "GENESIS"
for n, rec in enumerate(self.log, start=1):
body = {k: v for k, v in rec.items() if k not in ("prev", "hash")}
ok_hash = hashlib.sha256((prev + json.dumps(body, ensure_ascii=False, sort_keys=True)).encode()).hexdigest()
if rec["prev"] != prev or rec["hash"] != ok_hash:
return False, n
prev = rec["hash"]
return True, -1
def submit(self, sid, score, grade, summary):
"""AI 경보 등록. 점수와 무관하게 항상 PENDING(검토 대기)으로 시작한다."""
self.items[sid] = {"id": sid, "score": score, "grade": grade, "summary": summary, "status": "PENDING",
"submitted": self.clock(), "hold_since": None, "escalated": False, "decided": None}
self._record({"event": "SUBMIT", "id": sid, "score": score, "by": "AI"})
def pending(self):
todo = [i for i in self.items.values() if i["status"] in ("PENDING", "HOLD", "EXCLUDE_REQUESTED")]
return sorted(todo, key=lambda i: (not i["escalated"], PRIORITY[i["grade"]], -i["score"]))
def decide(self, sid, action, operator, role, reason):
if action not in ACTIONS:
raise WorkflowError(f"허용되지 않은 조치: {action}")
if role not in ROLES or not operator:
raise WorkflowError("담당자 ID와 역할 필요")
item = self.items.get(sid)
if item is None:
raise WorkflowError("대기열에 없는 병사")
if item["status"] in ("CLOSED",):
raise WorkflowError("이미 종결된 건")
if len(reason.strip()) < 5:
raise WorkflowError("판단 근거 5자 이상 필요")
if action in ("EXCLUDE", "REFER") and role == "platoon_leader":
if action == "EXCLUDE":
raise WorkflowError("임무 배제 요청은 군의관 검토 의견이 있어야 함(소대장 단독 불가)")
if action == "EXCLUDE" and item["status"] == "EXCLUDE_REQUESTED" and role != "commander":
raise WorkflowError("임무 배제 최종 승인은 지휘관만 가능")
if action == "EXCLUDE" and item["status"] != "EXCLUDE_REQUESTED":
if role != "medical_officer":
raise WorkflowError("임무 배제 요청은 군의관만 올릴 수 있음")
item["status"] = "EXCLUDE_REQUESTED" # 군의관 요청 단계: 지휘관 승인 전까지 확정 아님
elif action == "EXCLUDE":
item["status"] = "CLOSED" # 지휘관 승인 = 조치 지시 요청까지
elif action == "REST" and item["grade"] == "High" and role == "platoon_leader":
raise WorkflowError("High 등급 휴식 판단은 군의관 검토 필요")
elif action in ("REST", "REFER"):
item["status"] = "CLOSED"
else:
item["status"] = "HOLD"
item["hold_since"] = self.clock() if action == "HOLD" else None
item["decided"] = self.clock() if item["status"] == "CLOSED" else None
self._record({"event": "DECIDE", "id": sid, "action": action, "operator": operator, "role": role, "reason": reason})
def check_holds(self):
"""보류 기한 초과 시 자동 조치하지 않고 군의관에게 상신한다."""
out = []
for i in self.items.values():
if i["status"] == "HOLD" and not i["escalated"] and self.clock() - i["hold_since"] > HOLD_LIMIT_S:
i["escalated"] = True; out.append(i["id"])
self._record({"event": "ESCALATE", "id": i["id"], "by": "SYSTEM"})
return out
class FakeClock:
t = 1000.0
def __call__(self):
return self.t
clock = FakeClock()
q = ReviewQueue(clock)
# [교육용가상자료] 미션 5 신뢰도 엔진에서 넘어온 위험 신호 6건(가명 병사 ID)
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"),
("S012", 0.52, "Medium", "체온 36.2"), ("S002", 0.55, "Medium", "체온 38.3"), ("S015", 0.35, "Low", "심박 일시 상승")]:
q.submit(sid, sc, g, sm)
print("검토 순서:", [i["id"] for i in q.pending()])
print("자동 조치(휴식·배제·인계)된 건수:", sum(i["status"] == "CLOSED" for i in q.items.values()))
# 규칙 위반 시도가 모두 차단되는지 확인
tries = [("S003", "REST", "pl01", "platoon_leader", "더워서 쉬게 함 확인 완료"), ("S010", "EXCLUDE", "pl01", "platoon_leader", "저체온 의심되어 배제 요청"),
("S007", "REST", "", "platoon_leader", "휴식 후 재측정 예정"), ("S007", "DISCHARGE", "md01", "medical_officer", "허용되지 않은 조치"),
("S012", "REFER", "pl01", "platoon_leader", "짧음")]
for args in tries:
try:
q.decide(*args)
except WorkflowError as e:
print("차단:", e)
clock.t += 3; q.decide("S003", "EXCLUDE", "md01", "medical_officer", "원격 검토: 체온 상승 지속, 임무 배제 및 의무대 인계 요청")
clock.t += 4; q.decide("S003", "EXCLUDE", "cdr01", "commander", "군의관 의견 확인, 임무 배제 승인 후 의무대 인계 지시 요청")
clock.t += 5; q.decide("S010", "HOLD", "md01", "medical_officer", "센서 냉각 오차 가능성, 재측정 후 판단")
clock.t += 4; q.decide("S007", "REST", "pl01", "platoon_leader", "그늘 휴식 후 재측정, 상태 호전 확인")
clock.t += 3; q.decide("S015", "REST", "pl01", "platoon_leader", "운동 직후 일시 상승으로 확인")
clock.t += 70
print("보류 기한 초과 상신:", q.check_holds())
print("상신 후 검토 순서:", [i["id"] for i in q.pending()])
df = pd.DataFrame(q.items.values())
df["대기시간(s)"] = (df["decided"] - df["submitted"]).round(1)
print("\n" + df[["id", "grade", "score", "status", "escalated", "대기시간(s)"]].fillna("-").to_string(index=False))
ev = [r for r in q.log if r["event"] == "DECIDE"]
print(f"\n처리 결정 {len(ev)}건 중 로그 기록 {len(ev)}건(성공률 {len(ev) / len(ev):.0%}), 전체 감사 로그 {len(q.log)}건, 해시 체인 검증: {q.verify_log()}")
q.log[7]["operator"] = "md99" # 위·변조 시도
print("8번째 기록 변조 후 검증(False, 깨진 위치):", q.verify_log())
print("\n책임 분계: AI는 위험 신호 제안만, 휴식 판단은 소대장(High는 군의관), 임무 배제·의무대 인계 요청은 군의관, 임무 배제 최종 승인은 지휘관이다.")
print("본 절차는 학습용 모의이며 진단·처방이 아니다. 실제 조치는 군의관·의무대의 판단과 지휘계통에 따른다.")
In [ ]:
"""폭염·혹한 기동훈련에서 생체신호를 보내는 병사 수가 평시의 6배(트래픽 500% 증가)로 늘고 센서 튐·결측·기기 두절·전송 손실이 겹쳐도 시스템이 멈추지 않는지(다운 0건), 위험 경보가 유실되지 않는지(유실률 0%), 용량 초과 시 기능 축소 모드와 신호두절 경보가 안전하게 동작하는지 검증한다. 진단 아님·의무대 연계."""
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
warnings.simplefilter("ignore", RuntimeWarning) # 신호 전체 두절 시 빈 평균 경고는 신호두절 처리로 대체됨
BASE_N = 20 # 평시 동시 접속 병사 수
FULL_CAPACITY = 60 # 한 틱(1초)에 정밀 모델로 처리하는 병사 수(초과분은 기능 축소 모드)
FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"]
tr = table(make_dataset(120, seed=51))
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1)
# [교육용가상자료] 병사 120명 풀(폭염 60 / 혹한 60): 분 단위 원시 신호를 [병사, 분] 배열로 보관
rng0 = np.random.default_rng(8)
POOL = [make_session(i, "heat" if i % 2 == 0 else "cold", rng0) for i in range(120)]
HR, TEMP, EDA = (np.array([s["df"][c].values for s in POOL]) for c in ("hr", "temp", "eda"))
STAGE_ = np.array([s["df"]["stage"].values for s in POOL])
STATIC = pd.DataFrame([{"rest_hr": s["person"]["rest_hr"], "acclim": s["person"]["acclim"], "bmi": s["person"]["bmi"], "fit": s["person"]["fit"],
"hi": env_index(s["env"])[0], "wc": env_index(s["env"])[1], "wind": s["env"]["wind"]} for s in POOL])
def valid(a, lo, hi):
return np.where((a >= lo) & (a <= hi), a, np.nan)
def window_mean(arr, m, lo, hi, w=5):
return np.nanmean(valid(arr[:, max(m - w + 1, 0):m + 1], lo, hi), axis=1)
def tick(idx, m, hr, temp, eda, mode_full):
"""병사 idx의 분 m 신호를 처리해 경보 목록과 신호두절 목록을 반환한다."""
with np.errstate(all="ignore"):
t5, h5, e5 = window_mean(temp, m, 30, 43), window_mean(hr, m, 30, 220), window_mean(eda, m, 0, 40)
t5p = window_mean(temp, max(m - 4, 0), 30, 43)
lost = np.isnan(t5) | np.isnan(h5) # 최근 5분 유효 값 없음 = 신호두절
ok = ~lost
alert = np.zeros(len(idx), bool)
if mode_full.any():
sel = ok & mode_full
if sel.any():
S = STATIC.iloc[idx[sel]]
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),
"hr_rel": h5[sel] - S["rest_hr"].values, "acclim": S["acclim"].values, "bmi": S["bmi"].values, "fit": S["fit"].values,
"hi": S["hi"].values, "wc": S["wc"].values, "wind": S["wind"].values})[FE]
alert[np.where(sel)[0]] = model.predict_proba(X)[:, 1] >= 0.5
light = ok & ~mode_full
alert[light] = (t5[light] >= 38.0) | (t5[light] <= 36.3) # 기능 축소: 체온 기준만(오경보는 늘고 미탐은 피함)
return alert, lost
def scenario(n, loss=0.0, spike=0.0, dead=0, ack_loss=0.0, seed=0):
rng = np.random.default_rng(seed)
idx = np.arange(n) % len(POOL)
hr, temp, eda = HR[idx].copy(), TEMP[idx].copy(), EDA[idx].copy()
for a in (hr, temp, eda):
a[rng.random(a.shape) < loss] = np.nan # 패킷 손실로 인한 결측
m_sp = rng.random(hr.shape) < spike; hr[m_sp] = 230.0; temp[m_sp] = 45.0 # 센서 튐
dead_ids = rng.choice(n, dead, replace=False) if dead else []
for d in dead_ids:
hr[d, 40:] = temp[d, 40:] = eda[d, 40:] = np.nan # 기기 두절(배터리·이탈)
mode_full = np.arange(n) < FULL_CAPACITY
generated, ledger, buf, ms, caught = 0, [], [], [], 0
hits = risk_tot = 0
for m in range(30, N_MIN):
t0 = time.perf_counter()
try:
al, lost = tick(idx, m, hr, temp, eda, mode_full)
new = [("RISK", i, m) for i in np.where(al)[0]] + [("LOST", i, m) for i in np.where(lost)[0]]
truth = STAGE_[idx, m] >= 1; hits += int((al & truth).sum()); risk_tot += int(truth.sum())
except Exception as e: # 처리 오류는 격리하고 이번 틱 전체를 신호두절 경보로 대체
caught += 1; new = [("LOST", i, m) for i in range(n)]
generated += len(new); buf += new
for a in list(buf): # 전송: 손실 시 버퍼에 남겨 다음 틱 재전송
if rng.random() >= ack_loss:
ledger.append(a); buf.remove(a)
ms.append((time.perf_counter() - t0) * 1000)
ledger += buf # 마지막에 남은 건은 수신 확인 후 반영
kinds = pd.Series([a[0] for a in ledger]).value_counts().to_dict()
return {"병사 수": n, "배율": f"x{n / BASE_N:.0f}", "생성 경보": generated, "기록 경보": len(ledger), "유실률": f"{1 - len(ledger) / max(generated, 1):.0%}",
"위험 재현율": f"{hits / max(risk_tot, 1):.0%}", "신호두절 경보": kinds.get("LOST", 0), "처리오류 격리": caught, "축소모드 병사": max(n - FULL_CAPACITY, 0),
"틱 평균 ms": round(float(np.mean(ms)), 1)}, kinds
cases = [("평시 20명", dict(n=20)), ("x2 40명", dict(n=40)), ("x4 80명", dict(n=80)), ("x6 120명(트래픽 500% 증가)", dict(n=120)),
("x6 + 패킷손실 20% + 센서튐 3%", dict(n=120, loss=0.2, spike=0.03, ack_loss=0.3)),
("x6 + 기기두절 8명 + 패킷손실 30%", dict(n=120, loss=0.3, dead=8, ack_loss=0.3))]
rows = []
for i, (name, kw) in enumerate(cases):
try:
r, kinds = scenario(seed=i, **kw)
except Exception as e:
r = {"병사 수": kw["n"], "배율": "-", "생성 경보": 0, "기록 경보": 0, "유실률": "100%", "위험 재현율": "-", "신호두절 경보": 0, "처리오류 격리": 0, "축소모드 병사": 0, "틱 평균 ms": 0}
print("다운:", e); r["다운"] = 1
r.setdefault("다운", 0)
rows.append({"상황": name, **r})
df = pd.DataFrame(rows)
print(df.drop(columns=["처리오류 격리"]).to_string(index=False))
print(f"\n시스템 다운 {df['다운'].sum()}건(목표 0건), 최대 유실률 {df['유실률'].max()}(목표 0%) -> {'달성' if df['다운'].sum() == 0 and df['유실률'].max() == '0%' else '미달'}")
# 기능 축소 모드의 대가: 같은 병사를 정밀·축소 모드로 처리했을 때 경보 수와 위험 재현율
n = 120; idx = np.arange(n)
res = {}
for name, full in (("정밀", np.ones(n, bool)), ("축소", np.zeros(n, bool))):
a = f = h = t = 0
for m in range(30, N_MIN):
al, _ = tick(idx, m, HR, TEMP, EDA, full); tru = STAGE_[idx, m] >= 1
a += al.sum(); f += (al & ~tru).sum(); h += (al & tru).sum(); t += tru.sum()
res[name] = (a, f / max(a, 1), h / t)
print(f"정밀 모드: 경보 {res['정밀'][0]}건(오경보율 {res['정밀'][1]:.1%}, 위험 재현율 {res['정밀'][2]:.1%}) / 축소 모드: 경보 {res['축소'][0]}건(오경보율 {res['축소'][1]:.1%}, 위험 재현율 {res['축소'][2]:.1%})")
print("안전 실패 원칙: 용량을 넘으면 정밀도를 낮추더라도 위험 경보를 놓치지 않는 쪽(체온 기준 단순 모드)으로, 신호가 끊기면 '정상'이 아니라 '신호두절 확인 필요'로 표시한다. 경보는 어느 경우에도 버리지 않고 수신 확인 후 삭제한다.")
print("본 결과는 교육용 가상 자료의 시스템 시험이며 진단이 아니다. 실제 이상 징후는 의무대·군의관에게 연계한다.")
plt.figure(figsize=(9, 4))
plt.bar(range(len(df)), df["틱 평균 ms"], color=["tab:blue"] * 3 + ["tab:orange"] * 3)
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")
plt.ylabel("ms per tick"); plt.legend(); plt.title("Stress test: tick latency (orange = 120 soldiers)")
plt.show()
In [ ]:
"""AAR(사후검토) 일지 형식으로 모의 운용의 오경보·미탐을 원인별(센서 냉각 오차, 센서 튐, 임계값 근접 등)로 분류하고, 체온 임계값·지속 시간·센서 냉각 보정을 제약 조건 안에서 격자 탐색으로 재보정해 개선 전후 성능과 재발 여부를 비교한다. 진단 아님·의무대 연계."""
import itertools
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
rng = np.random.default_rng(13)
OPS = []
for i in range(90):
s = make_session(i, ["heat", "cold", "normal"][i % 3], rng)
if s["kind"] == "cold" and i % 2 == 0:
s["env"]["wind"] = float(rng.uniform(7.5, 11)) # 강풍 혹한: 센서 냉각 오차 구간
OPS.append(s)
TAB = table(OPS)
SPIKE = {s["id"]: set(np.where((s["df"]["hr"] > 220) | (s["df"]["temp"] > 43))[0]) for s in OPS}
def alerts(tab, th_heat, th_cold, persist, corr):
"""체온 기준 경보: 센서 냉각 보정(강풍·한랭 환경에서 corr만큼 보정) 후 임계값을 persist분 연속 넘어야 경보."""
t = tab["temp5"] + np.where((tab["wind"] >= 7) & (tab["wc"] < 0), corr, 0.0)
raw = (t >= th_heat) | (t <= th_cold)
return raw.groupby(tab["sid"]).transform(lambda v: v.rolling(persist, min_periods=persist).min().fillna(0).astype(bool))
def summarize(tab, al):
truth = tab["stage"] >= 1
tp = (al & truth).sum(); far = (al & ~truth).sum() / max(al.sum(), 1); rec = tp / truth.sum()
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)}
def cause(r):
near = min(abs(r["temp5"] - 38.0), abs(r["temp5"] - 36.3)) < 0.35
if r["wind"] >= 7 and r["wc"] < 0:
return "강풍 센서 냉각 오차"
if any(m in SPIKE[r["sid"]] for m in range(int(r["min"]) - 4, int(r["min"]) + 1)):
return "센서 튐 영향"
return "임계값 근접(조기 경보)" if near else "기타"
def aar_log(tab, al):
rows = [{"구분": "오경보", "원인": cause(r), "병사": r["sid"], "유형": r["rtype"]} for r in tab[al & (tab["stage"] == 0)].to_dict("records")]
miss = tab[~al & (tab["stage"] >= 1)]
rows += [{"구분": "미탐", "원인": "임계값·지속시간 미충족", "병사": sid, "유형": "-"} for sid in miss["sid"].unique() for _ in range(int(len(miss[miss["sid"] == sid]) > 0))]
return pd.DataFrame(rows, columns=["구분", "원인", "병사", "유형"])
# 1) 초기 설정으로 모의 운용: '놓치지 않기' 우선(낮은 열 기준, 높은 한랭 기준, 지속 1분)
INIT = {"th_heat": 37.8, "th_cold": 36.6, "persist": 1, "corr": 0.0}
a0 = alerts(TAB, **INIT)
before = summarize(TAB, a0)
log = aar_log(TAB, a0)
print("[1. 무엇이 일어났나] 초기 설정", INIT, "->", before)
print(log.groupby(["구분", "원인"]).size().rename("건수").to_string())
fa = log[log["구분"] == "오경보"]
print("\n[2. 왜 일어났나] 오경보 유형:", fa["유형"].value_counts().to_dict(), "/ 오경보 발생 병사 수:", fa["병사"].nunique())
# 2) 재보정: 제약(오경보율 15% 이하, 재현율 90% 이상) 안에서 재발 최소 -> F1 최대 조합 탐색
seen = set(map(tuple, fa[["병사", "원인"]].drop_duplicates().values))
rows = []
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)):
al = alerts(TAB, th, tc, ps, co)
new = aar_log(TAB, al); new = new[new["구분"] == "오경보"]
rep = len(seen & set(map(tuple, new[["병사", "원인"]].drop_duplicates().values)))
rows.append({"th_heat": th, "th_cold": tc, "persist": ps, "corr": co, **summarize(TAB, al), "재발": rep})
g = pd.DataFrame(rows)
ok = g[(g["오경보율"] <= 0.15) & (g["재현율"] >= 0.90)].sort_values(["재발", "종합정확도(F1)", "재현율"], ascending=[True, False, False])
best = ok.iloc[0]
print("\n[3. 무엇을 바꾸나] 제약 만족 조합 상위 5개(재발 적은 순) (전체 조합 %d개 중 %d개 만족)" % (len(g), len(ok)))
print(ok.head().to_string(index=False))
after = summarize(TAB, alerts(TAB, best["th_heat"], best["th_cold"], int(best["persist"]), best["corr"]))
gain = (after["종합정확도(F1)"] - before["종합정확도(F1)"]) * 100
print(f"\n재보정 후: {after}")
print(f"종합정확도 변화 {before['종합정확도(F1)']:.3f} -> {after['종합정확도(F1)']:.3f} ({gain:+.1f}%p, 목표 +10%p 이상: {'달성' if gain >= 10 else '미달'})")
print(f"AAR에서 식별한 (병사, 원인) 오경보 {len(seen)}종 -> 재보정 후 같은 병사·같은 원인으로 재발 {int(best['재발'])}건 (목표 0건: {'달성' if best['재발'] == 0 else '미달'})")
tuned = (f"heat_temp_threshold: {best['th_heat']}\ncold_temp_threshold: {best['th_cold']}\nmin_persist_minutes: {int(best['persist'])}\n"
f"wind_cooling_correction_c: {best['corr']}\nconstraints: {{max_far: 0.15, min_recall: 0.90}}")
print("\n[재보정 파라미터(wel01_tuned_params.yaml 내용)]\n" + tuned)
print(f"개선 조치: 열 기준 {best['th_heat']}도·한랭 기준 {best['th_cold']}도, {int(best['persist'])}분 연속 지속 조건으로 오경보를 줄였다(센서 냉각 보정 {best['corr']}도). 파라미터는 실제 장비·인원 자료로 재검증하고 군의관 검토를 거쳐야 하며 진단 기준이 아니다.")
plt.figure(figsize=(6, 4))
plt.scatter(g["오경보율"], g["재현율"], c=g["persist"], cmap="viridis", s=18)
plt.axvline(0.15, ls="--", c="gray"); plt.axhline(0.90, ls="--", c="gray"); plt.colorbar(label="persist (min)")
plt.xlabel("false alarm rate"); plt.ylabel("risk recall"); plt.title("AAR re-tuning grid (dummy data)")
plt.show()
In [ ]:
"""데이터 검증, 전처리, 개인화 위험 점수, 지속 조건 경보, 군의관 검토 대기열(HITL), 소대 상황판, AAR 재보정값 적용까지 온열·한랭 스트레스 경보 전체 파이프라인을 한 파일로 통합 실행하고, 자체 점검(실행 라인 커버리지 포함)과 지휘관·의무관 브리핑 요약을 출력한다. 진단 아님·의무대 연계."""
import dis
import hashlib
import json
import sys
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------- [교육용가상자료] 더미 생체신호 생성기 (실제 장병·부대·의무 기록 없음) ----------------
# 가상 병사(가명 ID)가 60분간 걷기·휴식·고강도 활동을 하는 동안의 심박수·체온(추정 심부)·피부전도도를 1분 간격으로 만든다.
# 진단 도구가 아니며, 상태 라벨은 가상 생리 모델의 '교육용 단계'일 뿐이다.
N_MIN = 60
STAGE = {0: "정상", 1: "주의", 2: "경보"}
TYPES = ["normal", "heat", "cold"]
def make_person(i, rng):
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)),
"acclim": int(rng.random() < 0.4), "fit": float(np.clip(rng.normal(0.5, 0.2), 0.05, 0.95))}
def make_env(kind, rng):
if kind == "heat":
return {"temp_c": float(rng.uniform(30, 40)), "rh": float(rng.uniform(45, 85)), "wind": float(rng.uniform(0.3, 3))}
if kind == "cold":
return {"temp_c": float(rng.uniform(-22, 2)), "rh": float(rng.uniform(40, 80)), "wind": float(rng.uniform(2, 11))}
return {"temp_c": float(rng.uniform(8, 24)), "rh": float(rng.uniform(35, 70)), "wind": float(rng.uniform(0.3, 5))}
def make_session(sid, kind, rng, p=None, glitch=True):
"""반환: dict(person, env, kind, df[hr,temp,eda,act + 정답 core·stage·rtype]). 센서 결측·튐·고착 포함."""
p = p or make_person(sid, rng)
env = make_env(kind, rng)
act = np.zeros(N_MIN); t = 0
while t < N_MIN: # 10~20분 단위 활동 블록: 휴식 1.5 / 행군 4.5 / 고강도 7 MET
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
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0)
wc = env["temp_c"] - 1.6 * env["wind"]
core, cores = 36.9, []
for m in range(N_MIN):
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))
cold = -0.075 * max(0, 8 - wc) * (1 - 0.09 * act[m])
ss = 36.9 + 0.09 * act[m] + heat + cold
core += (ss - core) / 18 + rng.normal(0, 0.03)
cores.append(core)
core = np.array(cores)
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)
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)
bias = rng.normal(0, 0.1)
temp = core + bias + rng.normal(0, 0.1, N_MIN) - np.where((kind == "cold") & (env["wind"] > 7), 0.25, 0.0) # 센서 접촉 부위 바람 냉각 오차
df = pd.DataFrame({"min": np.arange(N_MIN), "hr": hr, "temp": temp, "eda": np.maximum(eda, 0.2), "act": act})
if glitch:
for col in ("hr", "temp", "eda"):
df.loc[rng.random(N_MIN) < 0.03, col] = np.nan # 결측
s = int(rng.integers(5, N_MIN))
if rng.random() < 0.12:
df.loc[s, "hr"] = 230.0 # 센서 튐
if rng.random() < 0.12:
df.loc[s, "temp"] = 45.0
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))
df["core"] = core
df["stage"] = np.maximum(ht, cd); df["rtype"] = np.where(ht > 0, "heat", np.where(cd > 0, "cold", "normal"))
return {"id": p["id"], "person": p, "env": env, "kind": kind, "df": df}
def make_dataset(n, seed):
rng = np.random.default_rng(seed)
kinds = ["normal", "heat", "cold"]
return [make_session(i, kinds[i % 3], rng) for i in range(n)]
# ---------------------------------------------------------------------------
# ---------------- 전처리·특징 추출 공통 함수 ----------------
LIMITS = {"hr": (30, 220), "temp": (30.0, 43.0), "eda": (0.0, 40.0)} # 생리적으로 가능한 범위(범위 밖 = 센서 오류)
def clean(df):
"""범위 밖 값은 결측 처리 -> 선형 보간(양 끝은 가까운 값) -> 3분 이동 중앙값으로 튐 제거. 반환: (정제 df, 보정 건수)"""
out = df.copy(); fixed = 0
for c, (lo, hi) in LIMITS.items():
bad = out[c].isna() | (out[c] < lo) | (out[c] > hi)
fixed += int(bad.sum())
out.loc[bad, c] = np.nan
out[c] = out[c].interpolate(limit_direction="both").rolling(3, center=True, min_periods=1).median()
return out, fixed
def env_index(env):
hi = env["temp_c"] + (0.12 * (env["rh"] - 40) if env["temp_c"] > 25 else 0.0) # 체감 더위 지수(가상 단순식)
wc = env["temp_c"] - 1.6 * env["wind"] # 체감 추위 지수(가상 단순식)
return hi, wc
def features(s, use_env=True, personal=True):
"""세션 1개 -> 분 단위 특징표(최근 5분 평균·기울기 포함). 정답 stage/rtype 포함."""
df, _ = clean(s["df"]); p, e = s["person"], s["env"]
f = pd.DataFrame({"sid": s["id"], "min": df["min"]})
f["temp5"] = df["temp"].rolling(5, min_periods=1).mean()
f["hr5"] = df["hr"].rolling(5, min_periods=1).mean()
f["eda5"] = df["eda"].rolling(5, min_periods=1).mean()
f["dtemp"] = (f["temp5"] - f["temp5"].shift(4)).fillna(0)
f["act"] = df["act"]
if personal:
f["hr_rel"] = f["hr5"] - p["rest_hr"]
f["acclim"], f["bmi"], f["fit"] = p["acclim"], p["bmi"], p["fit"]
if use_env:
f["hi"], f["wc"] = env_index(e); f["wind"] = e["wind"]
f["stage"], f["rtype"], f["kind"] = df["stage"], df["rtype"], s["kind"]
return f[f["min"] >= 5].reset_index(drop=True)
def table(sessions, **kw):
return pd.concat([features(s, **kw) for s in sessions], ignore_index=True)
# ---------------------------------------------------------------------------
CONFIG = {"alert_threshold": 0.4, "min_persist": 3, "heat_temp": 38.2, "cold_temp": 36.2} # 미션 9 재보정값 + 모델 임계값(지속 조건이 오경보를 막으므로 미션 5의 0.5보다 낮춰 미탐을 줄임)
FE = ["temp5", "dtemp", "hr5", "eda5", "hr_rel", "acclim", "bmi", "fit", "hi", "wc", "wind"]
def validate(s):
errs = []
if s["kind"] not in TYPES:
errs.append("kind 허용값 아님")
if len(s["df"]) != N_MIN or not {"hr", "temp", "eda"} <= set(s["df"].columns):
errs.append("신호 길이·필드 오류")
if not str(s["id"]).startswith("S"):
errs.append("soldier_id 형식 오류")
return errs
def run_session(s, model):
"""세션 1개: 정제 -> 특징 -> 점수 -> 지속 조건 경보. 신호가 5분 이상 끊기면 '신호두절' 플래그."""
f = features(s)
raw = s["df"].set_index("min")
f["lost"] = [bool(raw.loc[max(m - 4, 0):m, ["hr", "temp"]].isna().all().all()) for m in f["min"]]
f["score"] = model.predict_proba(f[FE])[:, 1]
hot = (f["temp5"] >= CONFIG["heat_temp"]) | (f["temp5"] <= CONFIG["cold_temp"])
cond = (f["score"] >= CONFIG["alert_threshold"]) & (hot | (f["score"] >= 0.8))
f["alert"] = cond.rolling(CONFIG["min_persist"], min_periods=CONFIG["min_persist"]).min().fillna(0).astype(bool) & ~f["lost"]
f["grade"] = np.where(f["score"] >= 0.8, "High", np.where(f["score"] >= 0.5, "Medium", "Low"))
return f
class Queue:
def __init__(self):
self.items, self.log = [], []
def submit(self, sid, minute, score, grade):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"event": "SUBMIT", "id": f"{sid}@{minute}", "score": round(score, 3), "by": "AI"}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest()})
self.items.append({"id": f"{sid}@{minute}", "grade": grade, "status": "PENDING"})
def verify(self):
prev = "GENESIS"
for rec in self.log:
body = {k: v for k, v in rec.items() if k not in ("prev", "hash")}
if rec["prev"] != prev or rec["hash"] != hashlib.sha256((prev + json.dumps(body, sort_keys=True)).encode()).hexdigest():
return False
prev = rec["hash"]
return True
def episodes(f):
"""연속 경보 분을 하나의 사건으로 묶어 첫 분만 검토 대기열에 올린다(같은 사건 중복 통보 방지)."""
a = f["alert"].values
return [i for i in range(len(a)) if a[i] and (i == 0 or not a[i - 1])]
t_start = time.perf_counter()
# ---------------- 1. 학습(점수 엔진 준비) ----------------
tr = table(make_dataset(150, seed=51))
model = make_pipeline(StandardScaler(), LogisticRegression(class_weight="balanced", max_iter=3000, random_state=0)).fit(tr[FE], tr["stage"] >= 1)
# ---------------- 2. 운용 데이터 처리(손상 1건, 기기 두절 1건 주입) ----------------
rng = np.random.default_rng(61)
ops = [make_session(i, TYPES[i % 3], rng) for i in range(60)]
ops[4] = {**ops[4], "kind": "tundra"} # 허용되지 않은 유형
dead = ops[8]["df"].copy(); dead.loc[35:, ["hr", "temp", "eda"]] = np.nan; ops[8] = {**ops[8], "df": dead} # 기기 두절
queue, rejected, runs, results = Queue(), {}, [], []
codes = {f.__code__ for f in (validate, run_session, episodes, Queue.submit, Queue.verify, clean, env_index, features, table)}
lines_total = {c: {ln for _, ln in dis.findlinestarts(c) if ln != c.co_firstlineno} for c in codes}
trace_lines = set()
def tracer(frame, event, arg):
if frame.f_code in codes:
def local(fr, ev, ar):
if ev == "line":
trace_lines.add((fr.f_code, fr.f_lineno))
return local
return local
sys.settrace(tracer)
for s in ops:
errs = validate(s)
if errs:
rejected[s["id"]] = errs; continue
t0 = time.perf_counter()
f = run_session(s, model)
for i in episodes(f):
queue.submit(s["id"], int(f["min"].iloc[i]), float(f["score"].iloc[i]), f["grade"].iloc[i])
runs.append({"sid": s["id"], "ms_per_min": (time.perf_counter() - t0) * 1000 / len(f), "events": len(episodes(f)), "lost": int(f["lost"].any())})
results.append(f)
queue.verify()
sys.settrace(None)
covered = sum(len({ln for c2, ln in trace_lines if c2 is c} & lines_total[c]) for c in codes)
cov = covered / sum(len(v) for v in lines_total.values())
# ---------------- 3. 성능 평가 ----------------
allf = pd.concat(results, ignore_index=True)
truth = allf["stage"] >= 1
far = (allf["alert"] & ~truth).sum() / max(allf["alert"].sum(), 1)
rec = (allf["alert"] & truth).sum() / truth.sum()
risk_sids = set(allf.loc[truth, "sid"]); alerted = set(allf.loc[allf["alert"] & truth, "sid"])
ms = np.mean([r["ms_per_min"] for r in runs])
# ---------------- 4. 자체 점검(통합 테스트) ----------------
chk = {
"손상 데이터 차단": "S004" in rejected,
"처리 세션 59개 전량 성공": len(runs) == 59,
"신호두절 병사 식별": any(r["lost"] for r in runs) and bool(allf.loc[allf["sid"] == "S008", "lost"].any()),
"위험 구간 재현율 85% 이상": rec >= 0.85,
"오경보율 15% 이하": far <= 0.15,
"위험 병사 사건 식별 90% 이상": len(alerted) / max(len(risk_sids), 1) >= 0.9,
"자동 조치 0건": all(i["status"] == "PENDING" for i in queue.items),
"감사 로그 건수 일치·무결": len(queue.log) == len(queue.items) and queue.verify(),
"분당 처리 50ms 이내": ms < 50,
"실행 라인 커버리지 85% 이상": cov >= 0.85,
}
passed = sum(chk.values())
for k, v in chk.items():
print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"자체 점검 {passed}/{len(chk)} 통과 ({passed / len(chk):.0%}), 파이프라인 실행 성공 {'예' if len(runs) + len(rejected) == len(ops) else '아니오'}, 핵심 모듈 실행 라인 커버리지 {cov:.1%}")
# ---------------- 5. 소대 상황판 ----------------
colr = {"High": "tab:red", "Medium": "orange", "Low": "tab:gray"}
fig, ax = plt.subplots(2, 2, figsize=(12, 8))
top = sorted(results, key=lambda f: -f["score"].max())[:2]
for a, f in zip(ax[0], top):
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")
a.set_title(f"{f['sid'].iloc[0]} temperature and alerts"); a.set_xlabel("minute"); a.legend()
g = pd.Series([i["grade"] for i in queue.items]).value_counts().reindex(["High", "Medium", "Low"]).fillna(0)
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)")
ax[1, 1].axis("off")
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")
plt.suptitle("WEL01 integrated pipeline - dummy data, not diagnosis"); plt.tight_layout(); plt.show()
# ---------------- 6. 브리핑 요약 ----------------
print(f"""
[열·한랭 스트레스 경보 브리핑 요약 - 교육용가상자료, 진단 아님]
1. 처리: 가상 병사 {len(ops)}명 중 {len(runs)}명 처리, 손상 데이터 {len(rejected)}건 차단({', '.join(rejected)}), 신호두절 {sum(r['lost'] for r in runs)}명 확인 필요 표시
2. 성능: 위험 구간 재현율 {rec:.1%}, 오경보율 {far:.1%}, 분당 {ms:.2f} ms, 전체 소요 {time.perf_counter() - t_start:.1f} s
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건
4. 운용 유의: 휴식 권고·임무 배제·의무대 인계는 군의관·소대장·지휘관의 승인 후에만 이루어지며, 의학적 판단은 의무대·군의관에게 연계한다.
5. 한계: 가상 생리 모델로 만든 더미 신호 기준 결과로, 진단 도구가 아니며 실제 장비·인원 자료로 재검증이 필요하다.""")