Case 01 : 가장 좋은 모델을 찾아봅시다
페르소나
Sara는 이미지 분류 모델을 담당하는 ML 엔지니어이며, 지금 적절한 학습 설정을 찾는 작업을 수행하고 있습니다.
그녀는 learning rate, batch size, augmentation 적용 여부를 바꾸며 여러번 하이퍼파라미터 탐색(HPO) 실행을 수행합니다.
Sara가 원하는 것은 단순히 가장 높은 정확도 하나를 찾는 일이 아닙니다. 팀 리뷰에서 다음 질문에 즉시 근거를 제시할 수 있어야 합니다.
- 완료된 후보 중 정확도가 가장 높은 실행은 무엇인가요?
- 그 실행의 loss도 함께 확인했나요?
- 2등 후보보다 실제로 얼마나 좋아졌나요?
- 실패한 실행을 순위 계산에 잘못 포함하지 않았나요?
상황
Sara는 주말 동안 이미지 분류 학습을 8번 실행했습니다.
그 중 6번은 정상 완료되었고, 2번은 학습 중 실패했습니다.
각각의 완료된 실행에는 train 단계에서 측정한 accuracy와 loss가 기록되어 있습니다.
월요일 리뷰 회의에서 테크 리드 John이 묻습니다.
“배포 후보로 검토할 학습 실행은 무엇이고, 그 근거는 무엇인가요?”
이 케이스의 핵심은 이미 저장된 여러 실험 기록을 조회해, 가장 좋은 완료 실행을 선택하고 비교 근거까지 보여 주는 것입니다.
Contexta 없이 해결하려면
Contexta 없이 같은 질문에 답하려면, Sara는 보통 아래 작업을 직접 해야 합니다.
- 실험별 CSV, JSON, 노트북 출력 또는 폴더를 찾아 모읍니다.
- 파일 이름과 로그를 보고 성공 실행과 실패 실행을 수동으로 구분합니다.
- 파일마다 다른 형태로 저장된
accuracy와loss를 읽는 파싱 코드를 작성하거나 스프레드시트에 옮깁니다. - 정확도 기준으로 정렬한 뒤 최고 후보와 차점 후보의 값 차이를 직접 계산합니다.
- 회의 공유용 표나 요약문을 별도로 만듭니다.
예를 들어 결과물이 아래처럼 흩어져 있다고 생각해봅시다.
outputs/lr0001_bs32_aug_20250318_v3_FINAL.csv
notebooks/results_bs64_lr001_with_augmentation.json
scratch/experiment_march18_attempt2.txt
desktop/BEST_run_maybe_lr0005_bs64.csv
이 과정의 문제는 시간이 걸린다는 점만이 아닙니다.
실패 실행을 제외했는지, 어떤 값이 어느 학습 실행에서 나온 것인지, 다음 주에도 같은 결론을 다시 만들 수 있는지 등이 사람의 정리 상태에 의존합니다.
Contexta로 해결하기
Sara는 Contexta를 통해 파일을 재구성하지 않고 저장된 실행을 기준으로 조사할 수 있습니다.
project:image-classifier-hpo
├─ run:...exp-lr5e-4-bs32-aug completed train/accuracy=0.901 train/loss=0.267
├─ run:...exp-lr5e-4-bs64-aug completed train/accuracy=0.918 train/loss=0.231
├─ run:...exp-lr1e-4-bs32-aug failed
└─ ...
이 해결 흐름은 네 단계입니다.
| 단계 | Contexta API | 얻는 정보 |
|---|---|---|
| 저장된 실행 목록 읽기 | ctx.list_runs(PROJECT_NAME) | 전체 실행 수, 완료/실패 실행 구분 |
| 완료 실행의 결과 읽기 | ctx.get_run_snapshot(run_id) | 각 실행의 accuracy, loss 기록 |
| 최고 후보 선택하기 | ctx.select_best_run(..., "accuracy", stage_name="train") | 정확도 기준 최적 완료 실행 참조 |
| 근거 정리하기 | ctx.compare_runs(...), ctx.build_multi_run_report(...) | 2등 대비 변화량과 공유 가능한 실행 요약 |
분석 코드는 데이터 생성이나 모델 학습을 수행하지 않습니다.
.contexta 워크스페이스에 HPO 실행 기록이 이미 저장돼 있다는 가정 하에, 팀 리뷰에서 필요한 질문만 답합니다.
예제 코드
아래 코드는 이미 저장된 HPO 실행을 읽어 완료된 후보의 순위를 만들고, 최고 실행을 선택한 뒤 차점 후보와 비교합니다.
"""Investigate previously recorded HPO runs and select the best candidate."""
from pathlib import Path
from contexta import Contexta
from contexta.config import UnifiedConfig, WorkspaceConfig
PROJECT_NAME = "image-classifier-hpo"
ctx = Contexta(
config=UnifiedConfig(
project_name=PROJECT_NAME,
workspace=WorkspaceConfig(root_path=Path(".contexta")),
)
)
store = ctx.metadata_store
try:
runs = ctx.list_runs(PROJECT_NAME)
completed_run_ids = [run.run_id for run in runs if run.status == "completed"]
failed_run_ids = [run.run_id for run in runs if run.status == "failed"]
ranked_runs: list[tuple[str, str, float, float]] = []
for run_id in completed_run_ids:
snapshot = ctx.get_run_snapshot(run_id)
metrics = {
record.key: float(record.value)
for record in snapshot.records
if record.record_type == "metric" and record.value is not None
}
ranked_runs.append(
(
run_id,
snapshot.run.name,
metrics["accuracy"],
metrics["loss"],
)
)
ranked_runs.sort(key=lambda row: row[2], reverse=True)
print(
f"Runs: {len(runs)} total, "
f"{len(completed_run_ids)} completed, {len(failed_run_ids)} failed"
)
print("Rank Run name Accuracy Loss")
for rank, (_, name, accuracy, loss) in enumerate(ranked_runs, start=1):
print(f"#{rank:<4} {name:<30} {accuracy:.4f} {loss:.4f}")
best_run_id = ctx.select_best_run(
completed_run_ids,
"accuracy",
stage_name="train",
higher_is_better=True,
)
best_row = next(row for row in ranked_runs if row[0] == best_run_id)
print(f"\nSelected run: {best_row[1]}")
print(f"Selected accuracy: {best_row[2]:.4f}; loss: {best_row[3]:.4f}")
runner_up_id = ranked_runs[1][0]
comparison = ctx.compare_runs(runner_up_id, best_run_id)
print(f"\nComparison: {ranked_runs[1][1]} -> {best_row[1]}")
for stage in comparison.stage_comparisons:
for delta in stage.metric_deltas:
if delta.delta is not None:
print(f"{stage.stage_name}/{delta.metric_key}: {delta.delta:+.4f}")
report = ctx.build_multi_run_report(completed_run_ids)
print(f"\nReport: {report.title}")
print("Sections: " + ", ".join(section.title for section in report.sections))
finally:
store.close()
기록된 예제 데이터에 대해 실행하면 다음과 같은 결과를 얻습니다.
Runs: 8 total, 6 completed, 2 failed
Rank Run name Accuracy Loss
#1 exp-lr5e-4-bs64-aug 0.9180 0.2310
#2 exp-lr5e-4-bs32-aug 0.9010 0.2670
#3 exp-lr5e-4-bs128-aug 0.8970 0.2810
...
Selected run: exp-lr5e-4-bs64-aug
Selected accuracy: 0.9180; loss: 0.2310
Comparison: exp-lr5e-4-bs32-aug -> exp-lr5e-4-bs64-aug
train/accuracy: +0.0170
train/loss: -0.0360
Report: Multi Run Report: 6 runs
Sections: Summary, Run IDs, Metric Table, Completeness Notes
이 출력에서 Sara는 다음과 같은 것들을 제시할 수 있습니다.
- 최고 후보는
exp-lr5e-4-bs64-aug입니다. - 근거 : 2등 후보보다 정확도는
0.0170높으며 loss는0.0360낮습니다.
실패한 두 실행은 현황에는 포함되지만 최적 후보 선택에는 포함되지 않습니다.
코드 조각별로 이해하기
1. 완료 후보만 구분하기
runs = ctx.list_runs(PROJECT_NAME)
completed_run_ids = [run.run_id for run in runs if run.status == "completed"]
failed_run_ids = [run.run_id for run in runs if run.status == "failed"]
list_runs()는 프로젝트에 저장된 실행 목록을 반환합니다.
여기서는 먼저 실행 상태를 확인해 completed 실행만 모델 후보군으로 만듭니다.
실패한 실행을 삭제하는 것은 아닙니다.
실패 기록도 “어떤 설정이 학습을 끝내지 못했는가?”를 설명하는 중요한 실험 근거이므로 전체 개수와 실패 개수에는 남겨 둡니다.
단, 측정이 완료되지 않은 후보를 최고 정확도 선택에 섞지 않는 것이 핵심입니다.
2. 평가 근거를 기반으로 순위 만들기
for run_id in completed_run_ids:
snapshot = ctx.get_run_snapshot(run_id)
metrics = {
record.key: float(record.value)
for record in snapshot.records
if record.record_type == "metric" and record.value is not None
}
ranked_runs.append(
(run_id, snapshot.run.name, metrics["accuracy"], metrics["loss"])
)
ranked_runs.sort(key=lambda row: row[2], reverse=True)
get_run_snapshot()은 하나의 실행에 연결된 단계와 관측 기록을 한 번에 읽습니다.
이 사례에서는 각 완료 실행의 train 단계에 남겨진 accuracy와 loss를 추출합니다.
이 표가 단순한 스프레드시트 순위와 다른 점은 숫자가 실행 참조에 연결되어 있다는 것입니다.
0.9180은 복사해 둔 셀 값이 아니라, exp-lr5e-4-bs64-aug 실행에서 기록된 성능 근거로 다시 조회할 수 있습니다.
3. 최고 실행을 고르기
best_run_id = ctx.select_best_run(
completed_run_ids,
"accuracy",
stage_name="train",
higher_is_better=True,
)
select_best_run()에는 무엇을 기준으로 선택하는지 명시됩니다.
이 코드에서는 train 단계의 accuracy가 높을수록 좋은 후보라고 선언합니다.
이 호출은 “가장 좋아 보이는 파일명을 선택한다”가 아니라, 지정한 실행 집합에서 지정한 메트릭 값을 비교해 실행 참조를 반환합니다.
만약 배포 승인 과정에서 latency나 진단 이슈도 고려해야 한다면, 이 선택 결과를 최종 승인으로 취급하지 않고 추가 검사와 함께 사용해야 합니다.
4. 최고 후보와 2등 후보 비교하기
runner_up_id = ranked_runs[1][0]
comparison = ctx.compare_runs(runner_up_id, best_run_id)
for stage in comparison.stage_comparisons:
for delta in stage.metric_deltas:
if delta.delta is not None:
print(f"{stage.stage_name}/{delta.metric_key}: {delta.delta:+.4f}")
최고 실행만 말하면 '얼마나 앞섰는가?'가 빠집니다.
compare_runs()는 차점 실행을 기준으로 최고 실행의 메트릭이 어떻게 달라졌는지 보여 줍니다.
이 예제에서 train/accuracy: +0.0170은 최고 후보의 정확도가 차점보다 높다는 뜻이고, train/loss: -0.0360은 loss가 더 낮다는 뜻입니다.
정확도는 높을수록 좋고 loss는 낮을수록 좋으므로, 두 값 모두 선택한 후보를 지지합니다.
5. 회의용 요약 만들기
report = ctx.build_multi_run_report(completed_run_ids)
print(report.title)
print(", ".join(section.title for section in report.sections))
build_multi_run_report()는 완료 후보 전체에 대한 비교 결과를 리포트 문서 형태로 정리합니다.
원본 메트릭을 없애거나 대체하는 것이 아니라, 저장된 기록을 사람이 읽고 공유하기 쉬운 구조로 묶습니다.
이렇게 Sara는 회의에서 '완료/실패 실행 현황', '순위', '차점 대비 변화량', '비교 리포트'까지 다양한 요소를 제시할 수 있었습니다.
최종 답변
Contexta를 통해, Sara는 앞서 제시된 질문에 이런 식으로 답변할 수 있습니다.
-
Q1. 완료된 후보 중 정확도가 가장 높은 실행은 무엇인가요?
- A1. 완료된 후보 6개 중 최고 실행은
exp-lr5e-4-bs64-aug입니다.
- A1. 완료된 후보 6개 중 최고 실행은
-
Q2. 그 실행의 loss도 함께 확인했나요?
- A2. 이 실행의
train/accuracy는0.9180이고train/loss는0.2310입니다.
- A2. 이 실행의
-
Q3. 2등 후보보다 실제로 얼마나 좋아졌나요?
- A3. 2등 후보
exp-lr5e-4-bs32-aug와 비교하면 정확도는+0.0170높고 loss는-0.0360낮습니다.
- A3. 2등 후보
-
Q4. 실패한 실행을 순위 계산에 잘못 포함하지 않았나요?
- A4. 전체 실행은 8개였지만, 실패한 2개 실행은 순위 계산과 최적 후보 선택에서 제외했습니다.
따라서, Sara는 팀 리뷰에서 다음과 같이 답할 수 있습니다.
이번 실험에서는
exp-lr5e-4-bs64-aug를 후보로 검토하겠습니다.
그 근거로는 정확도가 약 92%로 2등 후보보다 약 2%p 높고, 손실도0.2310으로 2등 후보보다 0.04 정도 낮기 때문입니다.
선택: 예제 데이터 생성
이 섹션은 직접 코드를 실행해 보고 싶은 경우에만 필요합니다.
아래 데이터 준비 코드는 .contexta 워크스페이스에 이 케이스에서 사용하는 8개 HPO 실행과 완료 실행의 accuracy, loss 기록을 생성합니다.
"""Create canonical HPO run records used by the scattered-experiments case study."""
from pathlib import Path
from contexta import Contexta
from contexta.config import UnifiedConfig, WorkspaceConfig
from contexta.contract import MetricPayload, MetricRecord, Project, RecordEnvelope, Run, StageExecution
PROJECT_NAME = "image-classifier-hpo"
EXPERIMENTS = (
("exp-lr1e-3-bs32-aug", 0.874, 0.341, "completed", 45),
("exp-lr1e-3-bs64-aug", 0.891, 0.298, "completed", 42),
("exp-lr1e-3-bs128-noaug", 0.863, 0.372, "completed", 38),
("exp-lr5e-4-bs32-aug", 0.901, 0.267, "completed", 51),
("exp-lr5e-4-bs64-aug", 0.918, 0.231, "completed", 49),
("exp-lr5e-4-bs128-aug", 0.897, 0.281, "completed", 44),
("exp-lr1e-4-bs32-aug", 0.812, 0.489, "failed", 12),
("exp-lr2e-3-bs64-noaug", 0.841, 0.421, "failed", 8),
)
def metric_record(run_name: str, key: str, value: float, observed_at: str) -> MetricRecord:
run_ref = f"run:{PROJECT_NAME}.{run_name}"
stage_ref = f"stage:{PROJECT_NAME}.{run_name}.train"
return MetricRecord(
envelope=RecordEnvelope(
record_ref=f"record:{PROJECT_NAME}.{run_name}.{key}",
record_type="metric",
recorded_at=observed_at,
observed_at=observed_at,
producer_ref="docs.case01.seed",
run_ref=run_ref,
stage_execution_ref=stage_ref,
),
payload=MetricPayload(
metric_key=key,
value=value,
value_type="float",
unit="ratio" if key == "accuracy" else None,
aggregation_scope="stage",
subject_ref=stage_ref,
summary_basis="raw_observation",
),
)
ctx = Contexta(
config=UnifiedConfig(
project_name=PROJECT_NAME,
workspace=WorkspaceConfig(root_path=Path(".contexta")),
)
)
metadata = ctx.metadata_store
try:
metadata.projects.put_project(
Project(
project_ref=f"project:{PROJECT_NAME}",
name=PROJECT_NAME,
created_at="2025-03-18T08:55:00Z",
description="Image classifier hyperparameter search",
)
)
for name, accuracy, loss, status, minute in EXPERIMENTS:
run_ref = f"run:{PROJECT_NAME}.{name}"
stage_ref = f"stage:{PROJECT_NAME}.{name}.train"
ended_at = f"2025-03-18T09:{minute:02d}:00Z"
metadata.runs.put_run(
Run(
run_ref=run_ref,
project_ref=f"project:{PROJECT_NAME}",
name=name,
status=status,
started_at="2025-03-18T09:00:00Z",
ended_at=ended_at,
)
)
metadata.stages.put_stage_execution(
StageExecution(
stage_execution_ref=stage_ref,
run_ref=run_ref,
stage_name="train",
status=status,
started_at="2025-03-18T09:00:00Z",
ended_at=ended_at,
order_index=0,
)
)
if status == "completed":
ctx.record_store.append(metric_record(name, "accuracy", accuracy, ended_at))
ctx.record_store.append(metric_record(name, "loss", loss, ended_at))
print(f"Seeded {len(EXPERIMENTS)} runs in .contexta for project {PROJECT_NAME}.")
finally:
metadata.close()
코드를 hpo_seed.py로 저장한 뒤, Contexta가 설치된 환경에서 실행하세요.
uv run hpo_seed.py