Model Serialization
.joblib vs .pt vs .zip — 세 프레임워크의 모델 저장법
이 글은 AI_Finance(ALCHEMETRIC) 프로젝트가 7개 알고리즘을 3개 프레임워크로 관리하면서 만난 모델 직렬화(serialization) 문제를 다룬다. sklearn의 .joblib, PyTorch의 .pt, Stable-Baselines3의 .zip — 확장자 하나가 곧 프레임워크 계약이다.
목차
1. 왜 모델을 저장하는가?
머신러닝 모델의 학습은 비용이 크다. Random Forest는 수십 초, LSTM은 수 분, PPO는 수십 만 timestep을 소비한다. 매번 예측할 때마다 다시 학습하는 것은 현실적으로 불가능하다.
그래서 학습된 모델을 디스크에 저장(직렬화)하고, 예측 시점에 파일에서 로드(역직렬화)한다. 이것이 모델 서빙의 기본 원리이다.
ALCHEMETRIC 프로젝트는 3개 프레임워크에 걸쳐 7개 알고리즘을 운용한다.
| 프레임워크 | 알고리즘 | 확장자 | Phase |
|---|---|---|---|
| sklearn / XGBoost / LightGBM | RandomForest, XGBoost, LightGBM | .joblib | M3 |
| PyTorch | LSTM, Transformer | .pt | M4 Phase 8A |
| Stable-Baselines3 | DQN, PPO | .zip | M4 Phase 8B |
핵심: 각 프레임워크는 고유한 직렬화 계약(serialization contract)을 가지고 있다. 확장자가 다른 이유는 저장되는 내용과 구조가 근본적으로 다르기 때문이다.
2. .joblib — sklearn의 만능 피클
joblib은 Python 객체를 그대로 바이트 스트림으로 변환한다. sklearn, XGBoost, LightGBM 모두 학습 완료 상태를 Python 객체 하나로 표현할 수 있기 때문에, joblib.dump() 한 번이면 모델의 모든 것이 저장된다.
저장 — trainer.py
ALCHEMETRIC의 ModelTrainer는 모델, 스케일러, 임퓨터, 피처 목록을 하나의 dict로 묶어 단일 파일에 저장한다.
# trainer.py — 모델 저장 (L198-213) save_data = { "model": model, # fitted sklearn/XGBoost/LightGBM 객체 "scaler": scaler, # RobustScaler (fit 완료) "imputer": imputer, # SimpleImputer (fit 완료) "feature_columns": features, # 사용된 피처 목록 "algorithm": algorithm, # "random_forest" / "xgboost" / "lightgbm" "target_column": target_column, } joblib.dump(save_data, model_path) # rf_class_kospi_1d_v3.joblib
로드 — predictor.py
# predictor.py — _run_prediction() (L163-167) saved = joblib.load(ml_model.model_path) model = saved["model"] scaler = saved["scaler"] feature_columns = saved["feature_columns"] imputer = saved.get("imputer")
joblib.load() 한 번이면 모든 구성 요소가 즉시 사용 가능한 상태로 복원된다. 모델 아키텍처를 다시 정의할 필요가 없다. 이것이 sklearn 계열의 강점이다.
왜 pickle이 아니라 joblib인가? joblib은 내부적으로 pickle을 사용하지만, numpy 배열이 포함된 큰 객체에 대해 압축(zlib)과 메모리 매핑을 지원한다. sklearn 공식 문서도 joblib을 권장한다.
3. .pt — PyTorch의 state_dict 패턴
PyTorch는 sklearn과 다르다. 모델이 클래스 정의(architecture)와 가중치(state_dict)로 분리되어 있기 때문에, 단순히 객체를 통째로 dump할 수 없다. PyTorch 공식 가이드도 state_dict만 저장할 것을 권장한다.
저장 — dl_trainer.py
ALCHEMETRIC은 torch.save()에 dict를 넘긴다. 이 dict에는 가중치뿐 아니라, 모델을 재구성하는 데 필요한 모든 메타데이터를 함께 담는다.
# dl_trainer.py — 모델 저장 (L221-233) save_data = { "model_state_dict": trained_model.cpu().state_dict(), "model_class": algorithm, # "lstm" / "transformer" "model_params": params, # hidden_size, num_layers, d_model ... "scaler": scaler, # RobustScaler "imputer": imputer, # SimpleImputer "feature_columns": features, # 사용된 피처 목록 "n_features": n_features, "algorithm": algorithm, "target_column": target_column, "seq_len": seq_len, # 시퀀스 길이 (기본 20) } torch.save(save_data, model_path) # lstm_class_kospi_1d_v2.pt
로드 — dl_predictor.py
로드 과정이 joblib보다 한 단계 복잡하다. 아키텍처를 먼저 재구성한 뒤, 저장된 가중치를 주입해야 한다.
# dl_predictor.py — 로드 (L42-103) # 1단계: 아티팩트 로드 saved = torch.load(ml_model.model_path, map_location="cpu", weights_only=False) algorithm = saved["algorithm"] model_params = saved["model_params"] scaler = saved["scaler"] imputer = saved.get("imputer") feature_columns = saved["feature_columns"] seq_len = saved["seq_len"] # 2단계: 아키텍처 재구성 model = _build_model(algorithm, n_features, model_params) # 3단계: 가중치 주입 model.load_state_dict(saved["model_state_dict"]) model.eval()
여기서 _build_model()은 model_params에 기록된 hidden_size, num_layers, d_model 등을 읽어 LSTMClassifier 또는 TransformerClassifier를 생성한다. 아키텍처와 가중치의 shape이 정확히 일치해야 load_state_dict()가 성공한다.
왜 모델 객체를 통째로 torch.save()하지 않는가? PyTorch 모델을 pickle하면 클래스 정의의 모듈 경로까지 바인딩된다. 파일 구조를 리팩토링하면 역직렬화가 깨진다. state_dict만 저장하면 클래스 정의가 어디에 있든 상관없다.
4. .zip — stable-baselines3의 패키지
Stable-Baselines3(SB3)는 자체 직렬화 포맷을 사용한다. model.save()를 호출하면 .zip 파일이 생성되고, 내부에 정책 네트워크 가중치, 하이퍼파라미터, 옵티마이저 상태 등이 패키징된다.
문제는 SB3의 zip 안에는 scaler/imputer 같은 전처리 객체를 함께 넣을 수 없다는 것이다. SB3는 자기 내부 구조만 직렬화하기 때문이다. 그래서 ALCHEMETRIC은 별도의 _meta.joblib 파일을 같이 저장하는 전략을 사용한다.
저장 — rl_trainer.py
# rl_trainer.py — 모델 저장 (L153-171) model_path = str(SAVED_MODELS_DIR / f"{file_name}.zip") meta_path = str(SAVED_MODELS_DIR / f"{file_name}_meta.joblib") # 파일 1: SB3 모델 (.zip) model.save(model_path.replace(".zip", "")) # SB3가 자동으로 .zip 추가 # 파일 2: 전처리 메타데이터 (_meta.joblib) meta_data = { "scaler": scaler, # RobustScaler "imputer": imputer, # SimpleImputer "feature_columns": features, # 피처 목록 "algorithm": algorithm, # "dqn" / "ppo" "transaction_fee": transaction_fee, # 0.00015 "tax_rate": tax_rate, # 0.0020 "params": params, } joblib.dump(meta_data, meta_path)
결과적으로 RL 모델 하나를 서빙하려면 두 개의 파일이 항상 쌍으로 존재해야 한다.
saved_models/ ppo_rl_kospi_v1.zip # SB3 정책 네트워크 + 옵티마이저 ppo_rl_kospi_v1_meta.joblib # scaler, imputer, feature_columns
로드 — rl_predictor.py
# rl_predictor.py — 로드 (L59-68) # 1. SB3 모델 로드 model_cls = _SB3_MODELS[algorithm] # {"dqn": DQN, "ppo": PPO} sb3_model = model_cls.load(model_path.replace(".zip", "")) # 2. 메타데이터 로드 meta_path = model_path.replace(".zip", "_meta.joblib") meta = joblib.load(meta_path) scaler = meta["scaler"] imputer = meta.get("imputer") feature_columns = meta["feature_columns"]
주의: .zip 파일과 _meta.joblib 파일 중 하나라도 누락되면 예측이 실패한다. 모델 배포 시 항상 쌍으로 관리해야 한다.
5. 부속품 관리 — scaler, imputer, feature 목록
모델 가중치만 저장하면 예측할 수 없다. 학습 시 사용한 전처리 파이프라인이 함께 있어야 동일한 입력 변환이 보장된다. ALCHEMETRIC은 세 가지 부속품을 모델과 함께 관리한다.
| 부속품 | 역할 | 왜 함께 저장하는가? |
|---|---|---|
| RobustScaler | 피처 정규화 (중앙값, IQR 기반) | 학습 데이터의 통계량이 내장되어 있다. 새로 fit하면 값이 달라진다. |
| SimpleImputer | NaN 값을 중앙값으로 대체 | 학습 데이터의 중앙값이 내장되어 있다. 예측 시 동일한 값으로 대체해야 한다. |
| feature_columns | 피처 이름과 순서 | 피처 순서가 바뀌면 예측이 엉뚱해진다. 학습 시 확정된 순서를 그대로 써야 한다. |
프레임워크별 부속품 저장 위치
| 포맷 | scaler 위치 | imputer 위치 | feature_columns 위치 |
|---|---|---|---|
| .joblib | 같은 파일 (dict 안) | 같은 파일 (dict 안) | 같은 파일 (dict 안) |
| .pt | 같은 파일 (dict 안) | 같은 파일 (dict 안) | 같은 파일 (dict 안) |
| .zip | 별도 파일 (_meta.joblib) | 별도 파일 (_meta.joblib) | 별도 파일 (_meta.joblib) |
.joblib과 .pt는 Python dict에 어떤 객체든 넣을 수 있어서 단일 파일로 해결된다. 반면 .zip은 SB3 내부 포맷이 고정되어 있어 외부 객체를 끼워넣을 수 없다.
실전 교훈: scaler 없이 모델만 저장한 뒤 "왜 예측이 이상하지?"라고 삽질하는 경우가 많다. 전처리 파이프라인은 모델의 일부다.
6. 확장자 기반 라우팅 — predictor.py의 분기 전략
모델이 세 가지 포맷으로 저장되면, 예측 시점에 어떤 로딩 로직을 사용할지 결정해야 한다. ALCHEMETRIC은 이를 파일 확장자로 해결한다.
# predictor.py — _run_prediction() (L152-163) # RL 모델(.zip)은 RLPredictor로 위임 if ml_model.model_path and ml_model.model_path.endswith(".zip"): from .reinforcement import RLPredictor return RLPredictor().predict_single(ml_model, market, code) # DL 모델(.pt)은 DeepLearningPredictor로 위임 if ml_model.model_path and ml_model.model_path.endswith(".pt"): from .deep_learning import DeepLearningPredictor return DeepLearningPredictor().predict_single(ml_model, market, code) # 기본: sklearn/XGBoost/LightGBM (.joblib) saved = joblib.load(ml_model.model_path)
이 패턴에는 두 가지 설계 원칙이 담겨 있다.
첫째, 단일 진입점(Single Entry Point). API 라우터는 Predictor.predict_single()만 호출하면 된다. 모델이 sklearn인지 PyTorch인지 SB3인지 신경 쓸 필요 없다.
둘째, 확장자가 계약(convention)이다. DB의 model_path 컬럼에 저장된 파일 경로의 확장자만으로 분기가 결정된다. 별도의 model_framework 같은 컬럼이 필요 없다.
라우팅 흐름도
predict_single(code, market)
|
+- model_path.endswith(".zip")? --YES--> RLPredictor
| PPO.load() + joblib.load(_meta)
|
+- model_path.endswith(".pt")? --YES--> DeepLearningPredictor
| torch.load() + build_model() + load_state_dict()
|
+- else ------> joblib.load()
즉시 사용 가능
trainer.py도 동일한 패턴이다. 학습 라우터(ModelTrainer.train())는 is_reinforcement()과 is_deep_learning()으로 알고리즘 타입을 확인하고, RLTrainer 또는 DeepLearningTrainer로 위임한다. 학습과 예측 모두 동일한 위임 패턴을 따른다.
7. 세 방식 비교 정리
| 항목 | .joblib | .pt | .zip + _meta.joblib |
|---|---|---|---|
| 대상 프레임워크 | sklearn, XGBoost, LightGBM | PyTorch | Stable-Baselines3 |
| 알고리즘 | RF, XGB, LGBM | LSTM, Transformer | DQN, PPO |
| 직렬화 메커니즘 | pickle + zlib 압축 | torch.save (pickle 기반) | SB3 자체 zip 포맷 |
| 저장 API | joblib.dump(dict, path) | torch.save(dict, path) | model.save(path) + joblib.dump(meta, path) |
| 로드 API | joblib.load(path) | torch.load(path) + build + load_state_dict | PPO.load(path) + joblib.load(meta_path) |
| 파일 수 | 1개 | 1개 | 2개 (필수) |
| 부속품 포함 가능 | dict에 모두 포함 | dict에 모두 포함 | 불가 (별도 파일 필요) |
| 아키텍처 재구성 | 불필요 | 필수 (state_dict 로드 전) | 불필요 (SB3 내부 처리) |
| 로드 복잡도 | 낮음 (1줄) | 높음 (3단계) | 중간 (2파일) |
| Predictor 클래스 | Predictor (본체) | DeepLearningPredictor | RLPredictor |
8. 왜 통합하지 않았나?
"세 가지 포맷이 불편하니 하나로 통일하면 안 되나?"라는 질문이 자연스럽다. 결론부터 말하면, 통합하지 않는 것이 올바른 선택이다.
통합이 어려운 이유
1. 각 프레임워크의 직렬화는 프레임워크 계약이다. sklearn 모델은 Python 객체 그 자체가 모델이다. PyTorch 모델은 클래스 정의 + 가중치 텐서로 분리된다. SB3는 정책 네트워크 + 옵티마이저 + 환경 메타까지 자체 zip으로 묶는다. 이 근본적인 구조 차이를 추상화하는 것은 각 프레임워크의 내부를 우회하는 것과 같다.
2. 공식 문서의 권장 방식을 따르는 것이 유지보수에 유리하다. sklearn 공식 문서는 joblib/pickle을, PyTorch 공식 가이드는 state_dict를, SB3 공식 문서는 model.save()/load()를 권장한다. 각 프레임워크의 관례를 존중하면, 프레임워크 버전이 업그레이드되어도 호환성이 유지된다.
3. 통합의 비용 대비 이점이 작다. 확장자 기반 라우팅은 if문 두 줄이면 끝난다. 이 두 줄 대신 범용 직렬화 레이어를 만들면, 프레임워크가 추가될 때마다 그 레이어를 수정해야 한다.
대신 무엇을 통일했나
직렬화 포맷은 다르지만, 예측 인터페이스는 통일했다. 세 Predictor 클래스 모두 동일한 dict 구조를 반환한다.
# 모든 Predictor가 반환하는 공통 스키마 { "model_id": 42, "model_name": "ppo_rl_kospi_v1", "algorithm": "ppo", "prediction_date": "2025-01-15", "target_date": "2025-01-16", "predicted_class": 1, "probability_up": 0.7234, "probability_down": 0.2766, "signal": "BUY", "confidence": 0.7234, }
API 라우터나 시그널 생성기 입장에서는 모델이 RandomForest든 PPO든 상관없다. 저장은 프레임워크 방식대로, 출력은 공통 인터페이스로 — 이것이 ALCHEMETRIC이 선택한 설계 원칙이다.
설계 원칙: 직렬화 포맷은 프레임워크에 위임하고, 인터페이스(입출력 스키마)만 통일한다. 무리한 추상화보다 관례를 존중하는 것이 장기적으로 더 유지보수하기 좋다.
AI_Finance(ALCHEMETRIC) — Model Serialization
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #30 [모듈4-6] 딥러닝과 강화학습에서 Optuna를 쓰는 법은 ML과 다르다 (0) | 2026.03.19 |
|---|---|
| #29 [모듈4-5] RL 에이전트의 행동을 결정하는 보상 함수 설계 (0) | 2026.03.19 |
| #28 [모듈4-4] AI 에이전트가 스스로 매매 전략을 학습하는 법 (0) | 2026.03.19 |
| #27 [모듈4-3] CUDA, MPS, CPU - PyTorch 디바이스 감지와 혼합 정밀도 학습 (0) | 2026.03.19 |
| #26 [모듈4-2] DL Early Stopping & Overfitting (0) | 2026.03.18 |