머신러닝 실전 워크플로 (2) - 프로젝트 구조와 재현성
노트북 코드를 재현 가능한 프로젝트로 옮기는 단계입니다. 폴더 구조, 가상환경과 잠금 파일, 설정 분리, 시드 고정으로 같은 코드와 데이터에서 같은 결과가 나오게 만드는 법을 정리합니다.
머신러닝 실전 워크플로 시리즈의 2편입니다. 1편의 “문제 정의: 비즈니스 문제를 ML 문제로”에 이어집니다.
노트북에서 멈추는 코드
1편에서 풀 문제를 정했습니다. 이제 코드를 씁니다. 대부분 노트북에서 시작합니다. 데이터를 불러와 그려보고, 셀을 이리저리 고쳐가며 모델을 하나 학습시킵니다. 여기까지는 좋습니다. 노트북은 탐색에 맞는 도구입니다.
문제는 그 노트북을 다른 사람에게, 또는 세 달 뒤의 자신에게 넘길 때 드러납니다. 셀을 위에서 아래로 다시 돌리면 에러가 납니다. 중간에 지운 셀에서 만든 변수를 뒤 셀이 참조하기 때문입니다. 경로가 /Users/me/Downloads/data.csv로 박혀 있어 다른 컴퓨터에서는 파일을 못 찾습니다. pandas 버전이 달라 같은 코드가 다른 결과를 냅니다. 결국 “제 노트북에서는 됐는데요”라는 말만 남습니다.
이 단계가 푸는 문제는 하나입니다. 탐색용 노트북을, 누가 언제 어디서 돌려도 같은 결과가 나오는 프로젝트로 옮기는 것. 이것이 재현성이고, 이후 모든 단계(실험 추적, 서빙, 재학습)가 이 위에 얹힙니다. 재현되지 않는 코드는 배포할 수도, 두 실험을 비교할 수도 없기 때문입니다.
재현성이란 무엇인가
재현성의 정의는 짧습니다. 같은 코드와 같은 데이터를 넣으면 항상 같은 결과가 나온다. 검증 점수가 오늘은 0.912, 내일은 0.907로 흔들린다면 그 모델을 개선했는지 아닌지조차 판단할 수 없습니다. 재현성은 성능을 높이는 일이 아니라, 성능을 믿을 수 있게 만드는 일입니다.
같은 결과를 방해하는 요인은 정해져 있습니다.
- 환경: 라이브러리 버전이 다르면 같은 코드가 다르게 동작합니다.
- 무작위성: 데이터 분할, 가중치 초기화, 셔플에 난수가 쓰입니다. 시드를 고정하지 않으면 실행마다 값이 달라집니다.
- 숨은 상태: 노트북의 셀 실행 순서처럼, 코드에 드러나지 않는 상태에 결과가 의존합니다.
- 외부 입력: 경로, 하이퍼파라미터, 데이터 버전이 코드 밖에서 바뀌면 결과도 바뀝니다.
이 편은 앞의 세 가지를 다룹니다. 폴더 구조와 환경 고정으로 숨은 상태와 환경을, 설정 분리와 시드 고정으로 외부 입력과 무작위성을 잡습니다. 네 번째의 데이터 버전은 다음 편의 주제라 3편에서 이어집니다.
폴더 구조: 역할별로 나눈다
노트북 하나에 뭉쳐 있던 코드를 파일로 나눕니다. 목적은 정리정돈이 아니라 역할을 분리하는 데 있습니다. 데이터를 불러오는 코드, 피처를 만드는 코드, 학습하는 코드가 섞여 있으면 한 부분만 고쳐 다시 돌리기 어렵고 테스트도 못 합니다. 흔히 쓰는 구조는 이렇습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
project/
├── data/
│ ├── raw/ # 원본 데이터. 절대 수정하지 않는다
│ └── processed/ # 전처리 결과. 코드로 다시 만들 수 있다
├── src/
│ ├── data.py # 데이터 로드와 분할
│ ├── features.py # 피처 생성
│ ├── train.py # 학습
│ └── evaluate.py # 평가
├── configs/
│ └── config.yaml # 경로, 하이퍼파라미터, 시드
├── tests/
│ └── test_features.py # 피처 로직 검증
├── models/ # 학습된 모델 아티팩트
├── pyproject.toml # 의존성 선언
├── .python-version # 파이썬 버전 고정
└── README.md
핵심은 세 가지입니다. 첫째, data/raw는 읽기 전용으로 취급합니다. 원본을 덮어쓰면 재현의 출발점이 사라집니다. processed는 코드로 언제든 다시 만들 수 있으므로 버려도 됩니다. 둘째, src의 코드는 함수로 감쌉니다. 노트북의 최상위 셀 코드와 달리, 함수는 인자를 받아 값을 돌려주므로 테스트와 재사용이 됩니다. 셋째, configs와 코드를 분리합니다. 이 이유는 아래에서 따로 봅니다.
노트북을 아예 버리라는 뜻은 아닙니다. 탐색과 EDA는 여전히 노트북에서 하되, 확정된 로직은 src의 함수로 옮기고 노트북은 그 함수를 부르는 얇은 껍데기로 둡니다. 이렇게 하면 노트북에서 검증한 로직과 파이프라인이 도는 로직이 같아집니다.
환경 고정: 파이썬 버전과 의존성
“제 노트북에서는 됐는데요”의 절반은 환경 차이에서 옵니다. scikit-learn 1.3과 1.5는 기본값이 다르고, numpy 2.0은 일부 동작이 바뀌었습니다. 같은 코드가 같은 결과를 내려면 같은 버전 위에서 돌아야 합니다.
먼저 프로젝트 전용 가상환경을 만듭니다. 시스템 파이썬에 패키지를 직접 깔면 프로젝트끼리 버전이 충돌합니다.
1
2
3
python -m venv .venv # 프로젝트 폴더 안에 격리된 환경 생성
source .venv/bin/activate # 활성화. 이후 pip는 이 환경에만 설치한다
pip install pandas scikit-learn lightgbm pyyaml
여기서 끝내면 안 됩니다. 지금 설치된 정확한 버전을 잠금 파일에 적어 남겨야, 다른 사람이 똑같은 버전을 재현할 수 있습니다.
1
2
3
pip freeze > requirements.txt # 설치된 모든 패키지의 정확한 버전을 기록
# 다른 환경에서 복원할 때
pip install -r requirements.txt
pip freeze는 직접 설치한 패키지뿐 아니라 그것들이 끌고 온 하위 의존성까지 버전을 못 박습니다. 이것이 잠금 파일의 목적입니다. pandas만 적어두면 두 달 뒤 설치할 때 pandas가 최신으로 갱신되면서 하위 의존성도 함께 바뀌지만, 잠금 파일이 있으면 그때의 조합이 그대로 복원됩니다. uv나 poetry를 쓰면 직접 의존성(pyproject.toml)과 전체 잠금(uv.lock, poetry.lock)을 나눠 관리해 이 과정이 더 깔끔해집니다. 도구는 달라도 원칙은 같습니다. 선언한 의존성과 잠긴 버전을 둘 다 저장소에 커밋합니다.
파이썬 버전 자체도 고정합니다. 3.10과 3.12는 문법과 표준 라이브러리가 다릅니다. .python-version 파일에 버전을 적어두면 pyenv나 uv가 그 버전을 자동으로 맞춥니다.
1
3.11
잠금 파일과 파이썬 버전 파일은 반드시 버전관리에 커밋한다. 이것들이 없으면 코드만 공유해도 재현이 안 된다. 반대로
.venv폴더 자체는 커밋하지 않는다. 용량이 크고 운영체제마다 달라서, 잠금 파일로 각자 다시 만드는 것이 맞다.
설정 분리: 값을 코드 밖으로
노트북 코드에는 숫자와 경로가 곳곳에 박혀 있습니다. test_size=0.2, n_estimators=300, "data/raw/trips.parquet" 같은 값이 함수 여기저기에 흩어져 있으면 두 가지 문제가 생깁니다. 하이퍼파라미터를 하나 바꾸려고 코드를 헤집어야 하고, “이번 실험은 어떤 설정으로 돌렸는가”를 나중에 추적할 수 없습니다.
해결은 값과 코드를 나누는 것입니다. 경로, 하이퍼파라미터, 시드처럼 바뀔 수 있는 값을 설정 파일 하나로 모읍니다. YAML이 흔히 쓰입니다.
1
2
3
4
5
6
7
8
9
10
11
# configs/config.yaml
seed: 42
data:
raw_path: data/raw/trips.parquet
processed_path: data/processed/features.parquet
split:
test_size: 0.2
model:
n_estimators: 300
learning_rate: 0.05
max_depth: 8
코드는 이 파일을 읽어 값을 받아 씁니다. 하드코딩된 숫자가 코드에서 사라집니다.
1
2
3
4
5
6
7
8
9
10
import yaml
from lightgbm import LGBMRegressor
def load_config(path="configs/config.yaml"):
with open(path) as f:
return yaml.safe_load(f) # dict로 반환
cfg = load_config()
model = LGBMRegressor(**cfg["model"], random_state=cfg["seed"])
# n_estimators=300, learning_rate=0.05, max_depth=8 이 자동으로 들어간다
이렇게 하면 하이퍼파라미터를 바꿀 때 코드는 건드리지 않고 config.yaml의 숫자만 고칩니다. 더 중요한 이득은 추적입니다. 설정 파일을 실험마다 저장하면 “0.912를 낸 실험은 이 설정이었다”가 파일 하나로 남습니다. 이 설정 파일이 6편의 실험 추적에서 기록 대상이 되고, 서로 다른 실험을 비교하는 기준이 됩니다.
경로도 마찬가지입니다. /Users/me/... 같은 절대경로 대신 프로젝트 기준 상대경로를 설정에 두면, 다른 컴퓨터에서도 같은 코드가 돕니다.
시드 고정: 무작위성을 통제한다
머신러닝 코드 곳곳에 난수가 숨어 있습니다. train_test_split은 데이터를 무작위로 섞고, 트리 모델은 특성을 무작위로 샘플링하며, 신경망은 가중치를 난수로 초기화합니다. 시드를 고정하지 않으면 실행할 때마다 이 난수가 달라지고, 결과 점수도 함께 흔들립니다. 그러면 두 실험의 점수 차이가 진짜 개선인지 그냥 난수 운인지 구분할 수 없습니다.
시드를 고정하면 난수 생성기가 매번 같은 순서의 값을 내놓아, 실행이 결정적이 됩니다. 쓰는 라이브러리마다 각자의 시드가 있으므로 한 곳에서 모아 고정하는 함수를 둡니다.
1
2
3
4
5
6
7
8
9
10
import os, random
import numpy as np
def set_seed(seed=42):
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed) # 파이썬 기본 random
np.random.seed(seed) # numpy
# torch.manual_seed(seed) # 프레임워크를 쓰면 각자의 시드도 함께 고정한다
set_seed(cfg["seed"]) # 설정 파일의 seed 값을 넘긴다
개별 함수에 시드를 넘길 수 있으면 그쪽이 더 확실합니다. train_test_split(..., random_state=42), LGBMRegressor(random_state=42)처럼 인자로 직접 지정하면 그 호출만은 전역 시드와 무관하게 고정됩니다. 위 예에서 모델에 random_state=cfg["seed"]를 넘긴 것이 이 방식입니다.
시드를 고정해도 완벽한 결정성이 항상 보장되지는 않는다. GPU 연산이나 병렬 처리는 부동소수점 누적 순서가 실행마다 달라 미세한 차이를 남길 수 있다. 재현성이 특히 중요하면 프레임워크의 결정적 연산 옵션(예:
torch.use_deterministic_algorithms(True))까지 켜야 한다. 다만 대개는 시드 고정만으로 실험 비교에 필요한 수준의 재현성은 확보된다.
NYC 택시 파이프라인에서는
여기까지가 일반적인 원칙입니다. 이 원칙을 실제 데이터 하나로 끝까지 적용한 모습은 NYC 택시 파이프라인의 개발환경 편에서 볼 수 있습니다. 그 글은 택시 수요 예측 프로젝트의 폴더를 어떻게 나눴는지, 가상환경과 잠금 파일을 어떻게 잡았는지, 설정과 시드를 어디에 두었는지를 구체적인 파일로 보여줍니다. 이 편에서 세운 구조가 실제 프로젝트에서 어떤 모양이 되는지 나란히 읽으면 좋습니다.
전처리와 모델을 하나의 객체로 묶어 재현성을 코드 수준에서 보장하는 방법은 ML 기초의 누수와 파이프라인 편에서 다뤘습니다. 프로젝트 구조가 파일 수준의 재현성이라면, 파이프라인은 코드 수준의 재현성입니다. 둘은 서로를 받쳐줍니다.
정리
이 편은 노트북 코드를 재현 가능한 프로젝트로 옮기는 단계였습니다. 폴더로 역할을 나누고, 환경과 설정과 시드를 고정해, 같은 코드와 데이터에서 같은 결과가 나오는 토대를 만들었습니다. 이 토대 위에서 이후의 모든 단계가 돌아갑니다.
| 개념 | 한 줄 요약 |
|---|---|
| 재현성 | 같은 코드와 같은 데이터를 넣으면 항상 같은 결과가 나오는 것 |
| 폴더 구조 | 코드(src), 데이터(data), 설정(configs), 테스트(tests)를 역할별로 나눈다 |
| 원본 데이터 | data/raw는 읽기 전용. 덮어쓰면 재현의 출발점이 사라진다 |
| 환경 고정 | 파이썬 버전과 의존성을 잠금 파일로 못 박아 어디서든 같은 환경을 만든다 |
| 설정 분리 | 경로와 하이퍼파라미터를 코드에서 빼 config로. 코드 수정 없이 값만 바꾼다 |
| 시드 고정 | 난수를 통제해 실행마다 결과가 흔들리지 않게 한다. 실험 비교의 전제 |
| 노트북과의 차이 | 위에서 아래로 한 번에 돌려도 같은 결과가 나오면 프로젝트다 |
다음 편은 이 프로젝트에 데이터를 넣는 단계입니다. 데이터를 어디서 가져오고, 스키마를 어떻게 검증하며, 바뀌는 데이터를 어떻게 버전으로 관리하는지 봅니다.