Case 02 : 성능 저하의 원인을 찾아봅시다
페르소나
James는 이커머스 검색 파이프라인에서 사용하는 상품 분류 모델을 담당하는 ML 엔지니어입니다.
지난달 모델 정확도는 0.91이었지만, 이번 달 라이브러리 업그레이드 이후 재학습한 모델의 정확도는 0.87로 떨어졌습니다.
James가 답해야 하는 질문은 단순히 “성능이 떨어졌나요?”가 아닙니다.
- 어떤 메트릭이 얼마나 떨어졌나요?
- 같은 코드의 재학습인데 환경이 달라졌나요?
- Python 버전이나 패키지 변경이 원인 후보인가요?
- 지금 실행은 재현 가능한 상태로 기록되어 있나요?
상황
James는 정기 재학습을 수행했습니다. 학습 코드는 크게 바뀌지 않았지만, 팀은 의존성 업그레이드를 함께 진행했습니다.
문제는 이전 학습 시점의 실제 실행 환경이 파일로 남아 있지 않다는 점입니다.
저장소의 requirements.txt는 3주 전에 커밋된 파일이고, 학습 머신에 실제로 설치되어 있던 패키지 버전을 보장하지 않습니다.
수요일 리뷰 회의에서 테크 리드 William이 묻습니다.
“정확도가
0.91에서0.87로 떨어진 이유가 무엇인가요? 데이터, 코드, 환경 중 문제 있는 부분이 있는지 확인해보아야 할 것 같은데요.”
이 케이스의 핵심은 두 실행의 성능 차이와 실행 환경 차이를 같은 실행 기준으로 비교해서 회귀 원인 후보를 좁히는 것입니다.
Contexta 없이 해결하려면
Contexta 없이 같은 질문에 답하려면, James는 보통 아래 작업을 직접 해야 합니다.
- 지난달 학습 결과와 이번 달 학습 결과가 들어 있는 로그를 찾습니다.
- 두 실행의
accuracy,precision,recall값을 같은 기준으로 정리합니다. - 지난달 학습 환경을 알기 위해 오래된
requirements.txt, Dockerfile, CI 로그, 노트북 셀을 뒤집니다. - 이번 달 환경과 비교할 수 있도록 패키지 목록을 수동으로 diff합니다.
- 회귀 원인 후보를 정리해 재현성 감사(audit) 자료를 만듭니다.
이 과정의 문제는 requirements.txt가 학습 시점의 증거가 아니라는 점입니다.
파일이 커밋되지 않았거나, 학습 머신에서 임시로 패키지를 설치했다면 실제 환경은 복원하기 어렵습니다.
결국 “아마 torch가 바뀐 것 같다” 식의 추측으로 끝날 수도 있습니다.
Contexta로 해결하기
James는 Contexta를 통해 두 실행에 연결된 메트릭과 환경 스냅샷을 같은 단위로 비교합니다.
project:product-categorization
├─ run:last-month accuracy=0.910 torch=2.0.0 numpy=1.24.0
└─ run:this-month accuracy=0.870 torch=2.1.0 numpy=1.26.4
이 해결 흐름은 세 단계입니다.
| 단계 | Contexta API | 얻는 정보 |
|---|---|---|
| 메트릭 회귀 확인 | ctx.compare_runs(last_month_ref, this_month_ref) | 정확도, 정밀도, 재현율 변화량 |
| 환경 차이 확인 | ctx.compare_environments(last_month_ref, this_month_ref) | Python 버전 변경 여부와 패키지 diff |
| 재현성 감사 | ctx.audit_reproducibility(this_month_ref) | 환경 기록 상태, 패키지 수, 재현성 상태 |
분석 코드는 모델을 다시 학습하지 않습니다.
이미 저장된 두 실행을 기준으로 “성능이 얼마나 떨어졌고, 그 사이 어떤 환경 변화가 있었는가?”를 확인합니다.
예제 코드
아래 코드는 이미 저장된 지난달 실행과 이번 달 실행을 읽어, 메트릭 비교와 환경 비교를 수행합니다.
"""Analyze previously recorded performance-regression runs."""
from pathlib import Path
from contexta import Contexta
from contexta.config import UnifiedConfig, WorkspaceConfig
PROJECT_NAME = "product-categorization"
LAST_MONTH = f"run:{PROJECT_NAME}.last-month"
THIS_MONTH = f"run:{PROJECT_NAME}.this-month"
ctx = Contexta(
config=UnifiedConfig(
project_name=PROJECT_NAME,
workspace=WorkspaceConfig(root_path=Path(".contexta")),
)
)
store = ctx.metadata_store
try:
comparison = ctx.compare_runs(LAST_MONTH, THIS_MONTH)
print("Metric comparison: last-month -> this-month")
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}")
env_diff = ctx.compare_environments(LAST_MONTH, THIS_MONTH)
print("\nEnvironment changes:")
print(f"Python changed: {env_diff.python_version_changed}")
for change in env_diff.changed_packages:
print(f"{change.key}: {change.left_value} -> {change.right_value}")
audit = ctx.audit_reproducibility(THIS_MONTH)
print(f"\nReproducibility: {audit.reproducibility_status}")
print(f"Packages logged: {audit.package_count}")
finally:
store.close()
실행하면 다음과 같은 정보를 얻습니다.
Metric comparison: last-month -> this-month
train/accuracy: -0.0400
train/precision: -0.0420
train/recall: -0.0360
Environment changes:
Python changed: False
numpy: 1.24.0 -> 1.26.4
torch: 2.0.0 -> 2.1.0
Reproducibility: partial
Packages logged: 5
이 출력에서 James는 정확도 회귀와 함께 환경 변경 후보를 제시할 수 있습니다.
코드 조각별로 이해하기
1. 비교할 실행 참조 정하기
PROJECT_NAME = "product-categorization"
LAST_MONTH = f"run:{PROJECT_NAME}.last-month"
THIS_MONTH = f"run:{PROJECT_NAME}.this-month"
분석 코드는 .contexta 워크스페이스에 이미 저장된 두 실행을 직접 지정합니다.
LAST_MONTH는 성능이 좋았던 이전 실행이고, THIS_MONTH는 라이브러리 업그레이드 이후 정확도가 떨어진 실행입니다.
2. Contexta 클라이언트 열기
ctx = Contexta(
config=UnifiedConfig(
project_name=PROJECT_NAME,
workspace=WorkspaceConfig(root_path=Path(".contexta")),
)
)
Contexta는 현재 디렉터리의 .contexta 워크스페이스를 읽습니다.
워크스페이스에는 두 실행의 메트릭과 환경 스냅샷이 저장되어 있습니다.
3. 메트릭 회귀 확인하기
comparison = ctx.compare_runs(LAST_MONTH, THIS_MONTH)
print("Metric comparison: last-month -> this-month")
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()는 두 실행의 같은 메트릭을 찾아 변화량을 계산합니다.
이 예제에서는 accuracy, precision, recall이 모두 하락했기 때문에 단일 지표의 우연한 흔들림보다 넓은 성능 회귀로 볼 수 있습니다.
4. 환경 차이 확인하기
env_diff = ctx.compare_environments(LAST_MONTH, THIS_MONTH)
print("\nEnvironment changes:")
print(f"Python changed: {env_diff.python_version_changed}")
for change in env_diff.changed_packages:
print(f"{change.key}: {change.left_value} -> {change.right_value}")
compare_environments()는 두 실행의 Python 버전과 패키지 목록을 비교합니다.
여기서는 Python은 동일하지만 torch와 numpy가 바뀌었습니다.
다만 이 의존성의 변경이 꼭 원인이라고는 볼 수 없으며, 재실험을 통해 확인할 필요가 있습니다.
5. 재현 가능성 확인하기
audit = ctx.audit_reproducibility(THIS_MONTH)
print(f"\nReproducibility: {audit.reproducibility_status}")
print(f"Packages logged: {audit.package_count}")
audit_reproducibility()는 현재 실행이 나중에 재현 가능한 형태로 기록되어 있는지 확인합니다.
성능 회귀를 조사할 때 환경 정보가 남아 있으면, 패키지를 롤백하고 다시 실행하는 등 검증 가능한 다음 행동으로 이어질 수 있습니다.
최종 답변
Contexta를 통해, James는 앞서 제시된 질문에 이런 식으로 답변할 수 있습니다.
-
Q1. 어떤 메트릭이 얼마나 떨어졌나요?
- A1. 이번 달 실행은 지난달 실행보다 정확도가 약
4%p낮아졌습니다. 정밀도는 약4.2%p, 재현율은 약3.6%p낮아졌습니다.
- A1. 이번 달 실행은 지난달 실행보다 정확도가 약
-
Q2. 같은 코드의 재학습인데 환경이 달라졌나요?
- A2.
numpy는1.24.0에서1.26.4로,torch는2.0.0에서2.1.0으로 바뀌었습니다.
- A2.
-
Q3. Python 버전이나 패키지 변경이 원인 후보인가요?
- A3. Python은 바뀌지 않았으므로 일단 후보에서 제외할 수 있습니다.
-
Q4. 지금 실행은 재현 가능한 상태로 기록되어 있나요?
- A4. 재현성 상태는
partial이고, 패키지 5개가 기록되어 있습니다. 완전한 재현성이라고 단정하기보다는, 현재 기록을 바탕으로 패키지 롤백 실험을 진행할 수 있는 상태입니다.
- A4. 재현성 상태는
따라서, James는 리뷰 회의에서 다음과 같이 답할 수 있습니다.
정확도 하락의 원인은 실행 환경 차이로 보입니다.
이번 비교에서 확인된 차이는torch와numpy버전 변경으로 인한 환경 이슈입니다. 두 패키지를 지난달 버전으로 고정해 재실험하겠습니다.
선택: 예제 데이터 생성
이 섹션은 직접 코드를 실행해 보고 싶은 경우에만 필요합니다.
아래 데이터 준비 코드는 .contexta 워크스페이스에 지난달 실행과 이번 달 실행, 그리고 두 실행의 환경 스냅샷을 생성합니다.
"""Create performance-regression run records used by the regression case study."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from contexta import Contexta
from contexta.config import UnifiedConfig, WorkspaceConfig
from contexta.contract import (
EnvironmentSnapshot,
MetricPayload,
MetricRecord,
Project,
RecordEnvelope,
Run,
StageExecution,
)
PROJECT_NAME = "product-categorization"
_REC_COUNTER = 0
def _next_rid() -> str:
global _REC_COUNTER
_REC_COUNTER += 1
return f"r{_REC_COUNTER:05d}"
def _create_run(
store: Any,
record_store: Any,
project_name: str,
run_name: str,
accuracy: float,
precision: float,
recall: float,
started_at: str,
ended_at: str,
) -> str:
run_ref = f"run:{project_name}.{run_name}"
stage_ref = f"stage:{project_name}.{run_name}.train"
store.runs.put_run(
Run(
run_ref=run_ref,
project_ref=f"project:{project_name}",
name=run_name,
status="completed",
started_at=started_at,
ended_at=ended_at,
)
)
store.stages.put_stage_execution(
StageExecution(
stage_execution_ref=stage_ref,
run_ref=run_ref,
stage_name="train",
status="completed",
started_at=started_at,
ended_at=ended_at,
order_index=0,
)
)
for key, val in [("accuracy", accuracy), ("precision", precision), ("recall", recall)]:
record_store.append(
MetricRecord(
envelope=RecordEnvelope(
record_ref=f"record:{project_name}.{run_name}.{_next_rid()}",
record_type="metric",
recorded_at=ended_at,
observed_at=ended_at,
producer_ref="contexta.case02",
run_ref=run_ref,
stage_execution_ref=stage_ref,
completeness_marker="complete",
degradation_marker="none",
),
payload=MetricPayload(
metric_key=key,
value=val,
value_type="float64",
aggregation_scope="run",
),
)
)
return run_ref
def _capture_environment(
store: Any,
project_name: str,
run_name: str,
run_ref: str,
python_version: str,
platform: str,
packages: dict[str, str],
captured_at: str,
) -> None:
"""Store an environment snapshot linked to the run."""
if not hasattr(store, "environments"):
return
# environment_snapshot_ref must add exactly one component to run_ref
env_ref = f"environment:{project_name}.{run_name}.snapshot"
store.environments.put_environment_snapshot(
EnvironmentSnapshot(
environment_snapshot_ref=env_ref,
run_ref=run_ref,
captured_at=captured_at,
python_version=python_version,
platform=platform,
packages=packages,
environment_variables={},
)
)
def run_example(workspace: Path | str | None = None) -> dict[str, Any]:
"""Create two runs representing last month vs this month."""
if workspace is None:
root = Path(tempfile.mkdtemp(prefix="contexta-case02-"))
workspace_path = root / ".contexta"
else:
workspace_path = Path(workspace)
ctx = Contexta(
config=UnifiedConfig(
project_name=PROJECT_NAME,
workspace=WorkspaceConfig(root_path=workspace_path),
)
)
store = ctx.metadata_store
try:
store.projects.put_project(
Project(
project_ref=f"project:{PROJECT_NAME}",
name=PROJECT_NAME,
created_at="2025-02-01T00:00:00Z",
description="E-commerce product categorization model",
)
)
# Last month: stable environment, high accuracy
last_month_ref = _create_run(
store,
ctx.record_store,
PROJECT_NAME,
"last-month",
accuracy=0.91,
precision=0.893,
recall=0.908,
started_at="2025-02-15T08:00:00Z",
ended_at="2025-02-15T10:30:00Z",
)
_capture_environment(
store,
PROJECT_NAME,
"last-month",
last_month_ref,
python_version="3.11.0",
platform="linux",
packages={
"torch": "2.0.0",
"numpy": "1.24.0",
"scikit-learn": "1.2.2",
"transformers": "4.28.0",
"pandas": "1.5.3",
},
captured_at="2025-02-15T08:01:00Z",
)
# This month: torch bumped, numpy changed, accuracy dropped
this_month_ref = _create_run(
store,
ctx.record_store,
PROJECT_NAME,
"this-month",
accuracy=0.87,
precision=0.851,
recall=0.872,
started_at="2025-03-15T08:00:00Z",
ended_at="2025-03-15T10:45:00Z",
)
_capture_environment(
store,
PROJECT_NAME,
"this-month",
this_month_ref,
python_version="3.11.0",
platform="linux",
packages={
"torch": "2.1.0", # upgraded - potential culprit
"numpy": "1.26.4", # also changed
"scikit-learn": "1.2.2",
"transformers": "4.28.0",
"pandas": "1.5.3",
},
captured_at="2025-03-15T08:01:00Z",
)
return {
"last_month_run_id": last_month_ref,
"this_month_run_id": this_month_ref,
}
finally:
store.close()
def main() -> None:
from contextlib import redirect_stdout
import io
with redirect_stdout(io.StringIO()):
run_example(Path(".contexta"))
print(f"Seeded {PROJECT_NAME} data in .contexta.")
if __name__ == "__main__":
main()
코드를 performance_regression_seed.py로 저장한 뒤, Contexta가 설치된 환경에서 실행하세요.
uv run performance_regression_seed.py