Compare commits
2 Commits
4f6ce49704
...
9962544bf5
| Author | SHA1 | Date | |
|---|---|---|---|
| 9962544bf5 | |||
| 956c53ba23 |
19
.gemini/checkpoint-20260425.md
Normal file
19
.gemini/checkpoint-20260425.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# fumen-analyze 진행 리포트 (2026-04-25)
|
||||
|
||||
## 1. 현재 상태
|
||||
- **목표**: TJA 채보 기반 상수 예측 (Feature 및 Factor 방식)
|
||||
- **현재 상황**:
|
||||
- Feature 및 Factor 학습/추론 파이프라인 구축 완료.
|
||||
- XGBoost 및 LightGBM 모델 비교 중.
|
||||
- `script/compare_feature.ts` 실행 환경 점검 필요.
|
||||
- **데이터셋**: 1,000개 이상의 TJA 파일 파싱 및 피처화 성공.
|
||||
|
||||
## 2. 주요 개선사항
|
||||
- **파이프라인 고도화**: TypeScript로 전처리(Parse, Featurize, Factorize)를 모듈화하여 일관성 확보.
|
||||
- **예측 도구**: Python(XGBoost/LightGBM) 기반 예측 및 시각화 도구(`compare_feature.ts`, `compare_factor.ts`) 구성.
|
||||
- **상수 정답지**: `datas/measure.csv`를 기준으로 모델 성능 정량적 평가 체계 마련.
|
||||
|
||||
## 3. 남은 작업
|
||||
- `script/compare_feature.ts` 오류 디버깅 및 안정화.
|
||||
- 모델별(XGBoost vs LightGBM) 성능 최적화.
|
||||
- 에러 분포 시각화(`compare.png`)를 통한 모델 약점 보완.
|
||||
@@ -3,19 +3,20 @@
|
||||
## Technical Stack
|
||||
- **Runtime**: [Bun](https://bun.sh/)
|
||||
- **Language**: TypeScript (Preprocessing), Python (Machine Learning)
|
||||
- **ML Library**: [XGBoost](https://xgboost.readthedocs.io/), [scikit-learn](https://scikit-learn.org/)
|
||||
- **ML Library**: [XGBoost](https://xgboost.readthedocs.io/), [LightGBM](https://lightgbm.readthedocs.io/), [scikit-learn](https://scikit-learn.org/)
|
||||
- **TJA Parser**: [tja-parser](https://www.npmjs.com/package/tja-parser)
|
||||
|
||||
## Key Directories
|
||||
- `preprocess/`: TJA 파싱 및 피처 추출 로직 (TypeScript)
|
||||
- `script/`: 전처리, 학습 제어 스크립트
|
||||
- `train/`: XGBoost 학습 엔진 (Python)
|
||||
- `preprocess/`: TJA 파싱 및 피처/팩터 추출 로직 (TypeScript)
|
||||
- `script/`: 전처리, 학습 제어 및 결과 비교 스크립트
|
||||
- `train/`: 학습 엔진 (Python - XGBoost, LightGBM)
|
||||
- `predict/`: 추론 엔진 (Python)
|
||||
- `datas/tja/`: 원본 TJA 데이터셋
|
||||
- `datas/measure.csv`: 정답지 (상수 데이터)
|
||||
- `test/`: 학습 결과물 (model.pkl, scaler.pkl, features.json)
|
||||
- `output/`: 학습 모델(pkl/pkl), scaler, 결과 데이터(json/png)
|
||||
|
||||
## Data Flow
|
||||
1. `datas/tja/*.tja` → `script/preprocess.ts` → `test/features.json`
|
||||
2. `test/features.json` + `datas/measure.csv` → `train/train_xgboost.py` → `test/model.pkl`
|
||||
3. `test/model.pkl` + `test/features.json` → `predict/predict_xgboost.py` → Result
|
||||
1. `datas/tja/*.tja` → `preprocess/*.ts` → `temp.json` (features/factors)
|
||||
2. `temp.json` + `datas/measure.csv` → `train/*/train_*.py` → `model.*`, `scaler.*`
|
||||
3. `model.*` + `temp.json` → `predict/*/predict_*.py` → Prediction Result
|
||||
4. `script/compare_*.ts` → Evaluation (MAE) & Visualization (PNG)
|
||||
|
||||
BIN
abs_error_analysis.png
Normal file
BIN
abs_error_analysis.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
41
docs/3-2.lightgbm.md
Normal file
41
docs/3-2.lightgbm.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# LightGBM 기반 난이도 상수 예측 모델
|
||||
|
||||
이 문서는 프로젝트의 또 다른 학습 엔진인 LightGBM 모델의 구조, 하이퍼파라미터 및 특징을 설명합니다.
|
||||
|
||||
## 1. 모델 개요
|
||||
**LightGBM (Light Gradient Boosting Machine)**은 트리 기반 학습 알고리즘으로, XGBoost와 유사하지만 'Leaf-wise' 트리 성장 방식을 사용하여 더 빠르고 메모리 효율적인 학습이 가능합니다. 특히 대규모 데이터셋에서 높은 성능을 발휘하며, 본 프로젝트에서는 XGBoost와의 비교 및 앙상블 가능성을 열어두기 위해 도입되었습니다.
|
||||
|
||||
## 2. 하이퍼파라미터 설정
|
||||
`train/train_lightgbm.py`에 정의된 주요 설정값은 다음과 같습니다. XGBoost보다 더 깊고 복잡한 트리를 형성하도록 설정되어 있습니다.
|
||||
|
||||
| 파라미터 | 설정값 | 설명 |
|
||||
| :--- | :--- | :--- |
|
||||
| `learning_rate` | 0.02 | 학습률. XGBoost(0.05)보다 낮게 설정하여 더 정밀하게 수렴 |
|
||||
| `num_leaves` | 63 | 하나의 트리가 가질 수 있는 최대 잎(Leaf) 수. 복잡한 패턴 학습에 유리 |
|
||||
| `n_estimators` | 3000 | 결정 트리의 개수. 충분한 학습을 위해 크게 설정 |
|
||||
| `feature_fraction`| 0.9 | 각 트리 학습 시 사용할 피처 비율 (과적합 방지) |
|
||||
| `bagging_fraction`| 0.8 | 데이터 샘플링 비율 |
|
||||
| `bagging_freq` | 5 | 배깅 수행 빈도 |
|
||||
| `early_stopping` | 100 | 검증 오차가 개선되지 않을 경우 학습을 조기 종료하는 라운드 수 |
|
||||
|
||||
## 3. LightGBM 모델의 특징
|
||||
|
||||
### A. Leaf-wise 성장 방식
|
||||
- 대부분의 boosting 알고리즘이 Level-wise(수평 성장) 방식을 사용하는 것과 달리, LightGBM은 **Leaf-wise(수직 성장)** 방식을 사용합니다.
|
||||
- 이 방식은 손실(Loss)을 가장 많이 줄일 수 있는 잎 노드를 계속 분할하므로, 동일한 분할 횟수에서 Level-wise보다 더 낮은 손실을 달성할 수 있습니다.
|
||||
|
||||
### B. 실행 속도 및 효율성
|
||||
- XGBoost 대비 학습 속도가 매우 빠르며 메모리 사용량이 적습니다.
|
||||
- 많은 양의 데이터를 처리할 때 이점이 큽니다.
|
||||
|
||||
## 4. 학습 및 검증 프로세스
|
||||
XGBoost와 동일한 파이프라인을 따르며, 결과물은 다음과 같이 구분되어 저장됩니다.
|
||||
|
||||
- **모델 파일**: `model_lgbm.pkl`
|
||||
- **스케일러**: `scaler_lgbm.pkl`
|
||||
- **피처 목록**: `features_lgbm.txt`
|
||||
- **검증 결과**: `validate.json`, `validate.png`
|
||||
|
||||
## 5. 모델 평가
|
||||
- LightGBM은 하이퍼파라미터 변화에 민감하므로 과적합(Overfitting) 여부를 `validate.png`를 통해 상시 모니터링해야 합니다.
|
||||
- 현재 설정은 높은 `n_estimators`와 낮은 `learning_rate`를 통해 아주 미세한 채보의 차이까지 학습하는 것을 목표로 하고 있습니다.
|
||||
67
docs/4. train script.md
Normal file
67
docs/4. train script.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 4. 학습 스크립트 (Training Script) 가이드
|
||||
|
||||
이 문서는 `train/` 폴더 내의 학습 스크립트(`train_xgboost.py`, `train_lightgbm.py`)의 구조와 실행 과정을 설명합니다.
|
||||
|
||||
## 1. 실행 구조 및 파라미터
|
||||
|
||||
학습 스크립트는 명령행 인자(CLI Arguments)를 통해 제어됩니다.
|
||||
|
||||
### 실행 예시
|
||||
```bash
|
||||
python3 train/train_xgboost.py \
|
||||
--workingDir ./output/xgboost \
|
||||
--dataDir ./datas \
|
||||
--trainSize 1000 \
|
||||
--validSize 200
|
||||
```
|
||||
|
||||
### 파라미터 상세
|
||||
- `--workingDir`: 학습 결과물(`model.pkl`, `validate.json` 등)이 저장될 경로입니다.
|
||||
- `--dataDir`: 정답 데이터(`measure.csv`)가 위치한 경로입니다.
|
||||
- `--trainSize`: 전체 데이터셋 중 학습에 사용할 샘플 수입니다.
|
||||
- `--validSize`: 학습에 참여하지 않고 모델 평가(검증)에만 사용할 샘플 수입니다.
|
||||
|
||||
## 2. 데이터 처리 및 학습 파이프라인
|
||||
|
||||
스크립트 내부의 `train_model` 함수는 다음 순서로 동작합니다.
|
||||
|
||||
### A. 데이터 로드 및 매칭
|
||||
1. `workingDir`에서 `features.json`(전처리된 피처)을 읽어옵니다.
|
||||
2. `dataDir`에서 `measure.csv`(정답 난이도)를 읽어옵니다.
|
||||
3. `(songno, difficulty)`를 키로 사용하여 두 데이터를 매칭하고 하나의 데이터셋으로 결합합니다.
|
||||
|
||||
### B. 데이터셋 분리 (Train/Valid Split)
|
||||
1. 결합된 데이터셋을 `RANDOM_STATE(42)`를 기반으로 무작위로 섞습니다(Shuffle).
|
||||
2. 앞에서부터 `TRAIN_SIZE` 만큼을 학습 데이터로, 그 뒤의 `VALID_SIZE` 만큼을 검증 데이터로 엄격히 분리하여 모델의 일반화 성능을 보장합니다.
|
||||
|
||||
### C. 정규화 (Scaling)
|
||||
1. `StandardScaler`를 사용하여 피처의 평균을 0, 분산을 1로 조정합니다.
|
||||
2. `CONTINUE_TRAINING` 옵션이 켜져 있고 기존 `scaler.pkl`이 있다면 이를 로드하여 일관성을 유지합니다.
|
||||
|
||||
### D. 모델 학습 및 지속 학습 (Warm Start)
|
||||
1. 모델 객체를 생성하거나, 기존 `model.pkl`이 있다면 이를 로드합니다.
|
||||
2. 기존 모델이 있는 경우 이전 학습 상태를 유지한 채 새로운 데이터로 가중치를 미세 조정(Fine-tuning)합니다.
|
||||
|
||||
## 3. 검증 및 결과 시각화
|
||||
|
||||
학습 완료 직후, 모델이 학습 중에 보지 못한 검증 데이터셋(`X_valid`)을 사용하여 성능을 평가합니다.
|
||||
|
||||
1. **지표 계산**: MAE(Mean Absolute Error)와 오차 범위 ±0.1 이내의 정확도를 산출합니다.
|
||||
2. **validate.json 저장**: 검증된 각 곡의 실제값, 예측값, 오차를 에러 절댓값 내림차순으로 저장합니다.
|
||||
3. **validate.png 생성**: 에러의 분포를 한눈에 볼 수 있도록 산점도 그래프를 생성합니다. (X축: 에러 크기 순, Y축: 에러 절댓값)
|
||||
|
||||
## 4. 주요 산출물 (Outputs)
|
||||
|
||||
학습이 성공하면 `workingDir` 폴더에 다음 파일들이 생성/업데이트됩니다.
|
||||
|
||||
| 파일명 | 내용 |
|
||||
| :--- | :--- |
|
||||
| `model.pkl` | 학습된 모델 바이너리 |
|
||||
| `scaler.pkl` | 피처 정규화를 위한 Scaler 객체 |
|
||||
| `features.txt` | 학습에 사용된 피처 이름 목록 및 순서 |
|
||||
| `validate.json` | 검증 데이터셋에 대한 상세 예측 결과 (JSON) |
|
||||
| `validate.png` | 검증 에러 분포 시각화 그래프 (PNG) |
|
||||
|
||||
## 5. 주의사항
|
||||
- `features.json`이 먼저 생성되어 있어야 학습이 가능합니다 (`preprocess.ts` 선행 필요).
|
||||
- `TRAIN_SIZE + VALID_SIZE`가 전체 가용 데이터 수보다 크면 오류가 발생합니다.
|
||||
BIN
output/.DS_Store
vendored
BIN
output/.DS_Store
vendored
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
|
||||
bpm_avg
|
||||
bpm_change
|
||||
color_complexity
|
||||
density_avg
|
||||
density_peak
|
||||
note_count
|
||||
rhythm_complexity
|
||||
scroll_change
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
bpm_avg
|
||||
bpm_change
|
||||
color_complexity
|
||||
density_avg
|
||||
density_peak
|
||||
note_count
|
||||
rhythm_complexity
|
||||
scroll_change
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
17594
output/xgboost/temp.json
17594
output/xgboost/temp.json
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
[
|
||||
{
|
||||
"songno": "XODUS",
|
||||
"difficulty": "oni",
|
||||
"note_count": 1007,
|
||||
"density_avg": 8.14959935897436,
|
||||
"density_peak": 17,
|
||||
"bpm_avg": 202,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 14,
|
||||
"rhythm_complexity": 868,
|
||||
"color_complexity": 0.012565274045963932
|
||||
},
|
||||
{
|
||||
"songno": "Destructive Little Sister",
|
||||
"difficulty": "oni",
|
||||
"note_count": 1119,
|
||||
"density_avg": 7.917452830188679,
|
||||
"density_peak": 21,
|
||||
"bpm_avg": 247.5,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 17,
|
||||
"rhythm_complexity": 1079,
|
||||
"color_complexity": 0.016291673920775917
|
||||
},
|
||||
{
|
||||
"songno": "7 Wonders",
|
||||
"difficulty": "oni",
|
||||
"note_count": 794,
|
||||
"density_avg": 5.571929824561403,
|
||||
"density_peak": 12,
|
||||
"bpm_avg": 168,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 0,
|
||||
"rhythm_complexity": 726,
|
||||
"color_complexity": 0.006656416024691356
|
||||
},
|
||||
{
|
||||
"songno": "7 Wonders",
|
||||
"difficulty": "ura",
|
||||
"note_count": 1207,
|
||||
"density_avg": 8.47017543859649,
|
||||
"density_peak": 15,
|
||||
"bpm_avg": 168,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 0,
|
||||
"rhythm_complexity": 965,
|
||||
"color_complexity": 0.01463965596985236
|
||||
},
|
||||
{
|
||||
"songno": "Destruction 3 2 1",
|
||||
"difficulty": "oni",
|
||||
"note_count": 1160,
|
||||
"density_avg": 7.46659375,
|
||||
"density_peak": 15,
|
||||
"bpm_avg": 321.3209999999973,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 5,
|
||||
"rhythm_complexity": 1101,
|
||||
"color_complexity": 0.012682773678672012
|
||||
},
|
||||
{
|
||||
"songno": "Destruction 3 2 1",
|
||||
"difficulty": "ura",
|
||||
"note_count": 1491,
|
||||
"density_avg": 9.59714765625,
|
||||
"density_peak": 20,
|
||||
"bpm_avg": 321.32099999999707,
|
||||
"bpm_change": 0,
|
||||
"scroll_change": 8,
|
||||
"rhythm_complexity": 1417,
|
||||
"color_complexity": 0.028926671611070473
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
BIN
output/xgboost_factor/compare.png
Normal file
BIN
output/xgboost_factor/compare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
5817386
output/xgboost_factor/factors.json
Normal file
5817386
output/xgboost_factor/factors.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
output/xgboost_factor/model.pkl
Normal file
BIN
output/xgboost_factor/model.pkl
Normal file
Binary file not shown.
BIN
output/xgboost_factor/scaler.pkl
Normal file
BIN
output/xgboost_factor/scaler.pkl
Normal file
Binary file not shown.
5817386
output/xgboost_factor/temp.json
Normal file
5817386
output/xgboost_factor/temp.json
Normal file
File diff suppressed because it is too large
Load Diff
1408
output/xgboost_factor/validate.json
Normal file
1408
output/xgboost_factor/validate.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
output/xgboost_factor/validate.png
Normal file
BIN
output/xgboost_factor/validate.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
output/xgboost_feature/compare.png
Normal file
BIN
output/xgboost_feature/compare.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
File diff suppressed because it is too large
Load Diff
68
predict/factor/predict_lightgbm.py
Normal file
68
predict/factor/predict_lightgbm.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import joblib
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
# =========================================================
|
||||
# Configuration (Must match training)
|
||||
# =========================================================
|
||||
MAX_NOTES = 2000
|
||||
FACTOR_COUNT = 4
|
||||
INPUT_DIM = MAX_NOTES * FACTOR_COUNT
|
||||
|
||||
MODEL_FILENAME = "model.pkl"
|
||||
SCALER_FILENAME = "scaler.pkl"
|
||||
|
||||
def safe_float(value):
|
||||
try: return float(value)
|
||||
except: return 0.0
|
||||
|
||||
def predict():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workingDir", required=True)
|
||||
parser.add_argument("--songno", required=True)
|
||||
parser.add_argument("--factor", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
model_path = os.path.join(args.workingDir, MODEL_FILENAME)
|
||||
scaler_path = os.path.join(args.workingDir, SCALER_FILENAME)
|
||||
|
||||
if not os.path.exists(model_path):
|
||||
print(f"Model not found: {model_path}")
|
||||
return
|
||||
|
||||
model = joblib.load(model_path)
|
||||
scaler = joblib.load(scaler_path)
|
||||
|
||||
with open(args.factor or (Path(args.workingDir) / 'factors.json'), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
targets = [item for item in data if str(item["songno"]) == str(args.songno)]
|
||||
if not targets:
|
||||
print(f"No data found for songno: {args.songno}")
|
||||
return
|
||||
|
||||
results = []
|
||||
for item in targets:
|
||||
raw_factors = item["factors"]
|
||||
vector = np.zeros(INPUT_DIM, dtype=np.float32)
|
||||
|
||||
for i in range(min(len(raw_factors), MAX_NOTES)):
|
||||
for j in range(FACTOR_COUNT):
|
||||
vector[i * FACTOR_COUNT + j] = safe_float(raw_factors[i][j])
|
||||
|
||||
X = scaler.transform([vector])
|
||||
pred = model.predict(X)[0]
|
||||
|
||||
results.append({
|
||||
"songno": item["songno"],
|
||||
"diff": item["difficulty"],
|
||||
"predicted": float(pred)
|
||||
})
|
||||
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
predict()
|
||||
70
predict/factor/predict_xgboost.py
Normal file
70
predict/factor/predict_xgboost.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import joblib
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
# =========================================================
|
||||
# Configuration (Must match training)
|
||||
# =========================================================
|
||||
MAX_NOTES = 2000
|
||||
FACTOR_COUNT = 4
|
||||
INPUT_DIM = MAX_NOTES * FACTOR_COUNT
|
||||
|
||||
MODEL_FILENAME = "model.pkl"
|
||||
SCALER_FILENAME = "scaler.pkl"
|
||||
|
||||
def safe_float(value):
|
||||
try: return float(value)
|
||||
except: return 0.0
|
||||
|
||||
def predict():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workingDir", required=True)
|
||||
parser.add_argument("--songno", required=True)
|
||||
parser.add_argument("--factor", required=False) # Input factor JSON file
|
||||
args = parser.parse_args()
|
||||
|
||||
model_path = os.path.join(args.workingDir, MODEL_FILENAME)
|
||||
scaler_path = os.path.join(args.workingDir, SCALER_FILENAME)
|
||||
|
||||
if not os.path.exists(model_path):
|
||||
print(f"Model not found: {model_path}")
|
||||
return
|
||||
|
||||
model = joblib.load(model_path)
|
||||
scaler = joblib.load(scaler_path)
|
||||
|
||||
with open(args.factor or (Path(args.workingDir) / 'factors.json'), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Filter by songno
|
||||
targets = [item for item in data if str(item["songno"]) == str(args.songno)]
|
||||
if not targets:
|
||||
print(f"No data found for songno: {args.songno}")
|
||||
return
|
||||
|
||||
results = []
|
||||
for item in targets:
|
||||
raw_factors = item["factors"]
|
||||
vector = np.zeros(INPUT_DIM, dtype=np.float32)
|
||||
|
||||
for i in range(min(len(raw_factors), MAX_NOTES)):
|
||||
for j in range(FACTOR_COUNT):
|
||||
vector[i * FACTOR_COUNT + j] = safe_float(raw_factors[i][j])
|
||||
|
||||
# Scale and Predict
|
||||
X = scaler.transform([vector])
|
||||
pred = model.predict(X)[0]
|
||||
|
||||
results.append({
|
||||
"songno": item["songno"],
|
||||
"diff": item["difficulty"],
|
||||
"predicted": float(pred)
|
||||
})
|
||||
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
predict()
|
||||
181
script/compare_factor.ts
Normal file
181
script/compare_factor.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import Bun from 'bun';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
workingDir: { type: "string" },
|
||||
dataDir: { type: "string" },
|
||||
script: { type: "string" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.workingDir || !values.dataDir || !values.script) {
|
||||
console.error("Usage: bun run script/compare_factor.ts --workingDir <dir> --dataDir <dir> --script <python_script>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workingDir = values.workingDir;
|
||||
const dataDir = values.dataDir;
|
||||
const predictScript = values.script;
|
||||
const tempFileName = "temp.json";
|
||||
const tempFilePath = path.join(workingDir, tempFileName);
|
||||
|
||||
// 1. 전처리 실행 (Factor 기반)
|
||||
console.log("Step 1: Running factor preprocessing to temp.json...");
|
||||
const preprocessResult = Bun.spawnSync([
|
||||
"bun", "run", "script/preprocess_factor.ts",
|
||||
"--workingDir", workingDir,
|
||||
"--dataDir", dataDir,
|
||||
"--fileName", tempFileName
|
||||
]);
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
console.error("Preprocessing failed");
|
||||
console.error(preprocessResult.stderr.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. measure.csv 로드
|
||||
console.log("Step 2: Loading measure.csv...");
|
||||
const measurePath = path.join(dataDir, "measure.csv");
|
||||
const measureContent = fs.readFileSync(measurePath, "utf-8");
|
||||
const measureMap = new Map<string, number>();
|
||||
|
||||
measureContent.split("\n").forEach((line, index) => {
|
||||
if (index === 0 || !line.trim()) return;
|
||||
const parts = line.split(",");
|
||||
if (parts.length >= 3) {
|
||||
const constant = parts[0];
|
||||
const songno = parts[1];
|
||||
const diff = parts[2];
|
||||
measureMap.set(`${songno.trim()}_${diff.trim()}`, parseFloat(constant));
|
||||
}
|
||||
});
|
||||
|
||||
// 3. temp.json 로드하여 대상 곡 목록 추출
|
||||
const factors = JSON.parse(fs.readFileSync(tempFilePath, "utf-8"));
|
||||
const uniqueSongnos = Array.from(new Set(factors.map((f: any) => f.songno)));
|
||||
|
||||
// 4. 예측 및 비교
|
||||
console.log(`Step 3: Predicting and comparing ${uniqueSongnos.length} songs (Factor-based)...`);
|
||||
const comparisonResults: any[] = [];
|
||||
let processedCount = 0;
|
||||
|
||||
for (const songno of uniqueSongnos) {
|
||||
try {
|
||||
const predictProcess = Bun.spawnSync([
|
||||
"python3", predictScript,
|
||||
"--workingDir", workingDir,
|
||||
"--songno", songno as string,
|
||||
"--factor", tempFilePath // 파이썬 스크립트 인자명이 --factor로 고정되어 있는 경우를 가정
|
||||
]);
|
||||
|
||||
if (!predictProcess.success) {
|
||||
console.error(`\n[ERROR] Failed to predict songno ${songno}`);
|
||||
processedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const output = predictProcess.stdout.toString().trim();
|
||||
const jsonStart = output.indexOf('[');
|
||||
const jsonEnd = output.lastIndexOf(']') + 1;
|
||||
|
||||
if (jsonStart !== -1 && jsonEnd !== 0) {
|
||||
const predictions = JSON.parse(output.substring(jsonStart, jsonEnd));
|
||||
predictions.forEach((pred: any) => {
|
||||
const key = `${pred.songno}_${pred.diff}`;
|
||||
const actual = measureMap.get(key);
|
||||
if (actual !== undefined) {
|
||||
comparisonResults.push({
|
||||
songno: pred.songno,
|
||||
diff: pred.diff,
|
||||
actual: actual,
|
||||
predicted: pred.predicted,
|
||||
error: Math.abs(actual - pred.predicted)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
if (processedCount % 10 === 0 || processedCount === uniqueSongnos.length) {
|
||||
const percent = ((processedCount / uniqueSongnos.length) * 100).toFixed(1);
|
||||
process.stdout.write(`\rProgress: ${processedCount}/${uniqueSongnos.length} (${percent}%) `);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\nError processing songno ${songno}:`, err);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
console.log("\nPrediction finished.");
|
||||
|
||||
const avgError = comparisonResults.reduce((acc, curr) => acc + curr.error, 0) / comparisonResults.length;
|
||||
const resultData = {
|
||||
summary: {
|
||||
total_compared: comparisonResults.length,
|
||||
average_absolute_error: avgError,
|
||||
timestamp: new Date().toISOString(),
|
||||
script_used: predictScript,
|
||||
type: "factor"
|
||||
},
|
||||
details: comparisonResults.sort((a, b) => b.error - a.error)
|
||||
};
|
||||
|
||||
const comparePath = path.join(workingDir, "compare.json");
|
||||
fs.writeFileSync(comparePath, JSON.stringify(resultData, null, 2), "utf-8");
|
||||
|
||||
console.log(`\nComparison complete! Results saved to: ${comparePath}`);
|
||||
|
||||
// 6. 결과 시각화 (compare.png 생성)
|
||||
if (comparisonResults.length > 0) {
|
||||
console.log("Step 4: Generating comparison plot (compare.png)...");
|
||||
const plotPythonCode = `
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import json
|
||||
import os
|
||||
|
||||
with open('${comparePath}', 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data['details']:
|
||||
print("No details to plot.")
|
||||
exit(0)
|
||||
|
||||
df = pd.DataFrame(data['details'])
|
||||
df['abs_error'] = (df['actual'] - df['predicted']).abs()
|
||||
df = df.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
|
||||
plt.figure(figsize=(12, 6))
|
||||
plt.switch_backend('Agg')
|
||||
sns.scatterplot(x=df.index, y=df['abs_error'], alpha=0.6, s=20, color='royalblue')
|
||||
|
||||
plt.axhline(0.2, color='green', linestyle='--', linewidth=0.8, alpha=0.5, label='Target (0.2)')
|
||||
plt.axhline(0.5, color='blue', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
plt.axhline(1.0, color='red', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
|
||||
plt.ylim(0, max(3.5, df['abs_error'].max() + 0.5) if not df.empty else 3.5)
|
||||
plt.title('Comparison Absolute Error Distribution (Feature-based)', fontsize=14)
|
||||
plt.xlabel('Samples (Sorted by Error Magnitude)', fontsize=12)
|
||||
plt.ylabel('Absolute Error', fontsize=12)
|
||||
plt.grid(True, axis='y', alpha=0.3)
|
||||
|
||||
plot_path = os.path.join('${workingDir}', 'compare.png')
|
||||
plt.savefig(plot_path)
|
||||
print(f"Plot saved to: {plot_path}")
|
||||
`;
|
||||
|
||||
const plotProc = Bun.spawnSync(["python3", "-c", plotPythonCode]);
|
||||
if (plotProc.success) {
|
||||
console.log(plotProc.stdout.toString().trim());
|
||||
} else {
|
||||
console.error("Failed to generate plot:");
|
||||
console.error(plotProc.stderr.toString());
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const { values } = parseArgs({
|
||||
});
|
||||
|
||||
if (!values.workingDir || !values.dataDir || !values.script) {
|
||||
console.error("Usage: bun run script/compare.ts --workingDir <dir> --dataDir <dir> --script <python_script>");
|
||||
console.error("Usage: bun run script/compare_feature.ts --workingDir <dir> --dataDir <dir> --script <python_script>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ const predictScript = values.script;
|
||||
const tempFileName = "temp.json";
|
||||
const tempFilePath = path.join(workingDir, tempFileName);
|
||||
|
||||
// 1. 전처리 실행 (temp.json 생성하여 기존 features.json 보존)
|
||||
console.log("Step 1: Running preprocessing to temp.json...");
|
||||
// 1. 전처리 실행 (Feature 기반)
|
||||
console.log("Step 1: Running feature preprocessing to temp.json...");
|
||||
const preprocessResult = Bun.spawnSync([
|
||||
"bun", "run", "script/preprocess.ts",
|
||||
"bun", "run", "script/preprocess_feature.ts",
|
||||
"--workingDir", workingDir,
|
||||
"--dataDir", dataDir,
|
||||
"--fileName", tempFileName
|
||||
@@ -62,7 +62,7 @@ const features = JSON.parse(fs.readFileSync(tempFilePath, "utf-8"));
|
||||
const uniqueSongnos = Array.from(new Set(features.map((f: any) => f.songno)));
|
||||
|
||||
// 4. 예측 및 비교
|
||||
console.log(`Step 3: Predicting and comparing ${uniqueSongnos.length} songs...`);
|
||||
console.log(`Step 3: Predicting and comparing ${uniqueSongnos.length} songs (Feature-based)...`);
|
||||
const comparisonResults: any[] = [];
|
||||
let processedCount = 0;
|
||||
|
||||
@@ -77,39 +77,31 @@ for (const songno of uniqueSongnos) {
|
||||
|
||||
if (!predictProcess.success) {
|
||||
console.error(`\n[ERROR] Failed to predict songno ${songno}`);
|
||||
console.error(predictProcess.stderr.toString());
|
||||
processedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const output = predictProcess.stdout.toString().trim();
|
||||
// JSON 부분만 추출 (경고문 등이 섞여있을 경우 대비)
|
||||
const jsonStart = output.indexOf('[');
|
||||
const jsonEnd = output.lastIndexOf(']') + 1;
|
||||
|
||||
if (jsonStart === -1 || jsonEnd === 0) {
|
||||
console.error(`\n[ERROR] Invalid output format for songno ${songno}`);
|
||||
processedCount++;
|
||||
continue;
|
||||
|
||||
if (jsonStart !== -1 && jsonEnd !== 0) {
|
||||
const predictions = JSON.parse(output.substring(jsonStart, jsonEnd));
|
||||
predictions.forEach((pred: any) => {
|
||||
const key = `${pred.songno}_${pred.diff}`;
|
||||
const actual = measureMap.get(key);
|
||||
if (actual !== undefined) {
|
||||
comparisonResults.push({
|
||||
songno: pred.songno,
|
||||
diff: pred.diff,
|
||||
actual: actual,
|
||||
predicted: pred.predicted,
|
||||
error: Math.abs(actual - pred.predicted)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const predictions = JSON.parse(output.substring(jsonStart, jsonEnd));
|
||||
|
||||
predictions.forEach((pred: any) => {
|
||||
const key = `${pred.songno}_${pred.diff}`;
|
||||
const actual = measureMap.get(key);
|
||||
|
||||
if (actual !== undefined) {
|
||||
comparisonResults.push({
|
||||
songno: pred.songno,
|
||||
diff: pred.diff,
|
||||
actual: actual,
|
||||
predicted: pred.predicted,
|
||||
error: Math.abs(actual - pred.predicted)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
processedCount++;
|
||||
if (processedCount % 10 === 0 || processedCount === uniqueSongnos.length) {
|
||||
const percent = ((processedCount / uniqueSongnos.length) * 100).toFixed(1);
|
||||
@@ -122,27 +114,68 @@ for (const songno of uniqueSongnos) {
|
||||
}
|
||||
console.log("\nPrediction finished.");
|
||||
|
||||
// 5. 결과 분석 및 저장
|
||||
if (comparisonResults.length === 0) {
|
||||
console.error("No comparison results were generated.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const avgError = comparisonResults.reduce((acc, curr) => acc + curr.error, 0) / comparisonResults.length;
|
||||
const resultData = {
|
||||
summary: {
|
||||
total_compared: comparisonResults.length,
|
||||
average_absolute_error: avgError,
|
||||
timestamp: new Date().toISOString(),
|
||||
script_used: predictScript
|
||||
script_used: predictScript,
|
||||
type: "feature"
|
||||
},
|
||||
details: comparisonResults.sort((a, b) => b.error - a.error)
|
||||
};
|
||||
|
||||
const comparePath = path.join(workingDir, "compare.json");
|
||||
const comparePath = path.join(workingDir, "compare_feature.json");
|
||||
fs.writeFileSync(comparePath, JSON.stringify(resultData, null, 2), "utf-8");
|
||||
|
||||
console.log(`\nComparison complete!`);
|
||||
console.log(`Total compared: ${comparisonResults.length}`);
|
||||
console.log(`Average Error: ${avgError.toFixed(4)}`);
|
||||
console.log(`Results saved to: ${comparePath}`);
|
||||
console.log(`\nComparison complete! Results saved to: ${comparePath}`);
|
||||
|
||||
// 6. 결과 시각화 (compare.png 생성)
|
||||
if (comparisonResults.length > 0) {
|
||||
console.log("Step 4: Generating comparison plot (compare.png)...");
|
||||
const plotPythonCode = `
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import json
|
||||
import os
|
||||
|
||||
with open('${comparePath}', 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if not data['details']:
|
||||
print("No details to plot.")
|
||||
exit(0)
|
||||
|
||||
df = pd.DataFrame(data['details'])
|
||||
df['abs_error'] = (df['actual'] - df['predicted']).abs()
|
||||
df = df.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
|
||||
plt.figure(figsize=(12, 6))
|
||||
plt.switch_backend('Agg')
|
||||
sns.scatterplot(x=df.index, y=df['abs_error'], alpha=0.6, s=20, color='royalblue')
|
||||
|
||||
plt.axhline(0.2, color='green', linestyle='--', linewidth=0.8, alpha=0.5, label='Target (0.2)')
|
||||
plt.axhline(0.5, color='blue', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
plt.axhline(1.0, color='red', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
|
||||
plt.ylim(0, max(3.5, df['abs_error'].max() + 0.5) if not df.empty else 3.5)
|
||||
plt.title('Comparison Absolute Error Distribution (Feature-based)', fontsize=14)
|
||||
plt.xlabel('Samples (Sorted by Error Magnitude)', fontsize=12)
|
||||
plt.ylabel('Absolute Error', fontsize=12)
|
||||
plt.grid(True, axis='y', alpha=0.3)
|
||||
|
||||
plot_path = os.path.join('${workingDir}', 'compare.png')
|
||||
plt.savefig(plot_path)
|
||||
print(f"Plot saved to: {plot_path}")
|
||||
`;
|
||||
|
||||
const plotProc = Bun.spawnSync(["python3", "-c", plotPythonCode]);
|
||||
if (plotProc.success) {
|
||||
console.log(plotProc.stdout.toString().trim());
|
||||
} else {
|
||||
console.error("Failed to generate plot:");
|
||||
console.error(plotProc.stderr.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import Bun from 'bun';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'node:fs';
|
||||
import { featurize } from '../preprocess/featurize';
|
||||
import { parseTja } from '../preprocess/parse';
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
tja: {
|
||||
type: "string"
|
||||
},
|
||||
workingDir: {
|
||||
type: "string"
|
||||
},
|
||||
script: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.tja || !values.workingDir || !values.script) {
|
||||
console.error("Usage: bun script/predict_tja.ts --tja <path_to_tja> [--workingDir <dir>] [--script <python_script>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tjaPath = values.tja;
|
||||
if (!fs.existsSync(tjaPath)) {
|
||||
console.error(`File not found: ${tjaPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workingDir = values.workingDir!;
|
||||
const pythonScript = values.script!;
|
||||
|
||||
if (!fs.existsSync(pythonScript)) {
|
||||
console.error(`Python script not found: ${pythonScript}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. TJA 파싱 및 피처 추출
|
||||
const tjaContent = fs.readFileSync(tjaPath, 'utf-8');
|
||||
const songno = path.basename(tjaPath, '.tja');
|
||||
const parsed = parseTja(tjaContent);
|
||||
|
||||
const features: any[] = [];
|
||||
if (parsed.oni) {
|
||||
features.push({
|
||||
songno,
|
||||
difficulty: 'oni',
|
||||
...featurize(parsed.oni)
|
||||
});
|
||||
}
|
||||
if (parsed.edit) {
|
||||
features.push({
|
||||
songno,
|
||||
difficulty: 'ura',
|
||||
...featurize(parsed.edit)
|
||||
});
|
||||
}
|
||||
|
||||
if (features.length === 0) {
|
||||
console.error("No Oni or Ura difficulty found in the TJA file.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 임시 피처 파일 저장
|
||||
const tempFeaturePath = path.join(workingDir, `temp_predict_${Date.now()}.json`);
|
||||
fs.writeFileSync(tempFeaturePath, JSON.stringify(features, null, 2), 'utf-8');
|
||||
|
||||
try {
|
||||
// 3. Python 예측 스크립트 실행
|
||||
const proc = Bun.spawnSync([
|
||||
"python3",
|
||||
pythonScript,
|
||||
"--workingDir", workingDir,
|
||||
"--feature", tempFeaturePath,
|
||||
"--songno", songno
|
||||
]);
|
||||
|
||||
if (proc.success) {
|
||||
console.log(proc.stdout.toString());
|
||||
} else {
|
||||
console.error("Prediction failed:");
|
||||
console.error(proc.stderr.toString());
|
||||
}
|
||||
} finally {
|
||||
// 4. 임시 파일 삭제
|
||||
if (fs.existsSync(tempFeaturePath)) {
|
||||
fs.unlinkSync(tempFeaturePath);
|
||||
}
|
||||
}
|
||||
68
script/predict_tja_factor.ts
Normal file
68
script/predict_tja_factor.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import Bun from 'bun';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'node:fs';
|
||||
import { factorize } from '../preprocess/factorize';
|
||||
import { parseTja } from '../preprocess/parse';
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
tja: { type: "string" },
|
||||
workingDir: { type: "string" },
|
||||
script: { type: "string" }
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.tja || !values.workingDir || !values.script) {
|
||||
console.error("Usage: bun script/predict_tja_factor.ts --tja <path_to_tja> --workingDir <dir> --script <python_script>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tjaPath = values.tja;
|
||||
const workingDir = values.workingDir!;
|
||||
const pythonScript = values.script!;
|
||||
|
||||
const tjaContent = fs.readFileSync(tjaPath, 'utf-8');
|
||||
const songno = path.basename(tjaPath, '.tja');
|
||||
const parsed = parseTja(tjaContent);
|
||||
|
||||
const factors: any[] = [];
|
||||
if (parsed.oni) {
|
||||
factors.push({
|
||||
songno,
|
||||
difficulty: 'oni',
|
||||
factors: factorize(parsed.oni)
|
||||
});
|
||||
}
|
||||
if (parsed.edit) {
|
||||
factors.push({
|
||||
songno,
|
||||
difficulty: 'ura',
|
||||
factors: factorize(parsed.edit)
|
||||
});
|
||||
}
|
||||
|
||||
if (factors.length === 0) {
|
||||
console.error("No Oni or Ura difficulty found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tempFactorPath = path.join(workingDir, `temp_predict_fact_${Date.now()}.json`);
|
||||
fs.writeFileSync(tempFactorPath, JSON.stringify(factors, null, 2), 'utf-8');
|
||||
|
||||
try {
|
||||
const proc = Bun.spawnSync([
|
||||
"python3",
|
||||
pythonScript,
|
||||
"--workingDir", workingDir,
|
||||
"--feature", tempFactorPath, // 파이썬 스크립트 인자명이 --feature로 고정되어 있다고 가정
|
||||
"--songno", songno
|
||||
]);
|
||||
|
||||
if (proc.success) console.log(proc.stdout.toString());
|
||||
else console.error("Prediction failed:", proc.stderr.toString());
|
||||
} finally {
|
||||
if (fs.existsSync(tempFactorPath)) fs.unlinkSync(tempFactorPath);
|
||||
}
|
||||
49
script/predict_tja_feature.ts
Normal file
49
script/predict_tja_feature.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Bun from 'bun';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'node:fs';
|
||||
import { featurize } from '../preprocess/featurize';
|
||||
import { parseTja } from '../preprocess/parse';
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
tja: { type: "string" },
|
||||
workingDir: { type: "string" },
|
||||
script: { type: "string" }
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.tja || !values.workingDir || !values.script) {
|
||||
console.error("Usage: bun script/predict_tja_feature.ts --tja <path_to_tja> --workingDir <dir> --script <python_script>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tjaPath = values.tja;
|
||||
const workingDir = values.workingDir!;
|
||||
const pythonScript = values.script!;
|
||||
|
||||
const tjaContent = fs.readFileSync(tjaPath, 'utf-8');
|
||||
const songno = path.basename(tjaPath, '.tja');
|
||||
const parsed = parseTja(tjaContent);
|
||||
|
||||
const features: any[] = [];
|
||||
if (parsed.oni) features.push({ songno, difficulty: 'oni', ...featurize(parsed.oni) });
|
||||
if (parsed.edit) features.push({ songno, difficulty: 'ura', ...featurize(parsed.edit) });
|
||||
|
||||
if (features.length === 0) {
|
||||
console.error("No Oni or Ura difficulty found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tempFeaturePath = path.join(workingDir, `temp_predict_feat_${Date.now()}.json`);
|
||||
fs.writeFileSync(tempFeaturePath, JSON.stringify(features, null, 2), 'utf-8');
|
||||
|
||||
try {
|
||||
const proc = Bun.spawnSync(["python3", pythonScript, "--workingDir", workingDir, "--feature", tempFeaturePath, "--songno", songno]);
|
||||
if (proc.success) console.log(proc.stdout.toString());
|
||||
else console.error("Prediction failed:", proc.stderr.toString());
|
||||
} finally {
|
||||
if (fs.existsSync(tempFeaturePath)) fs.unlinkSync(tempFeaturePath);
|
||||
}
|
||||
65
script/preprocess_factor.ts
Normal file
65
script/preprocess_factor.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import Bun from 'bun';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs, { mkdirSync } from 'node:fs';
|
||||
import { factorize } from '../preprocess/factorize';
|
||||
import { parseTja } from '../preprocess/parse'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
workingDir: {
|
||||
type: "string"
|
||||
},
|
||||
dataDir: {
|
||||
type: "string"
|
||||
},
|
||||
fileName: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
|
||||
if (!values.dataDir || !values.workingDir) {
|
||||
console.error("--workingDir --dataDir --fileName");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workingDir = values.workingDir ?? '';
|
||||
if (!fs.existsSync(workingDir)) mkdirSync(workingDir)
|
||||
const dataDir = values.dataDir ?? '';
|
||||
|
||||
const tjaDir = path.join(dataDir, 'tja');
|
||||
const files = fs.readdirSync(tjaDir);
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.tja')) continue;
|
||||
const tja = fs.readFileSync(path.join(tjaDir, file), 'utf-8');
|
||||
const songno = path.basename(file, '.tja');
|
||||
try {
|
||||
const parsed = parseTja(tja);
|
||||
const courses = [
|
||||
{ diff: 'oni', data: parsed?.oni },
|
||||
{ diff: 'ura', data: parsed?.edit }
|
||||
];
|
||||
|
||||
for (const course of courses) {
|
||||
if (course.data) {
|
||||
results.push({
|
||||
songno,
|
||||
difficulty: course.diff,
|
||||
factors: factorize(course.data)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Error] ${file}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = path.join(workingDir, values.fileName ?? 'factors.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(results, null, 2), 'utf-8');
|
||||
console.log(`Successfully saved factors to ${outputPath}`);
|
||||
106
script/train_factor.ts
Normal file
106
script/train_factor.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import Bun from 'bun';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { factorize } from '../preprocess/factorize';
|
||||
import { parseTja } from '../preprocess/parse'
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv,
|
||||
options: {
|
||||
workingDir: {
|
||||
type: "string"
|
||||
},
|
||||
dataDir: {
|
||||
type: "string"
|
||||
},
|
||||
script: {
|
||||
type: "string"
|
||||
},
|
||||
trainSize: {
|
||||
type: 'string'
|
||||
},
|
||||
validSize: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.dataDir || !values.workingDir || !values.script) {
|
||||
console.error("Usage: bun run script/train_factor.ts --workingDir <dir> --dataDir <dir> --script <python_script> --trainSize <num> --validSize <num>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. factors.json 생성 (없을 경우)
|
||||
generateFactors();
|
||||
|
||||
// 2. 파이썬 학습 스크립트 실행
|
||||
console.log(`Starting training with factor-based script: ${values.script}`);
|
||||
const child = spawn("python3", [
|
||||
values.script,
|
||||
"--workingDir", values.workingDir,
|
||||
"--dataDir", values.dataDir,
|
||||
"--trainSize", (Number(values.trainSize) || 1000).toString(),
|
||||
"--validSize", (Number(values.validSize) || 200).toString(),
|
||||
]);
|
||||
|
||||
child.stdout.pipe(process.stdout);
|
||||
child.stderr.pipe(process.stderr);
|
||||
|
||||
child.on("close", (code) => {
|
||||
console.log(`Training process exited with code ${code}`);
|
||||
process.exit(code || 0);
|
||||
});
|
||||
|
||||
// functions
|
||||
function generateFactors() {
|
||||
const workingDir = values.workingDir ?? '';
|
||||
if (!fs.existsSync(workingDir)) fs.mkdirSync(workingDir, { recursive: true });
|
||||
|
||||
const factorsPath = path.join(workingDir, 'factors.json');
|
||||
if (fs.existsSync(factorsPath)) {
|
||||
console.log('factors.json already exists, skipping generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = values.dataDir ?? '';
|
||||
const tjaDir = path.join(dataDir, 'tja');
|
||||
if (!fs.existsSync(tjaDir)) {
|
||||
console.error(`TJA directory not found: ${tjaDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(tjaDir).filter(f => f.endsWith('.tja'));
|
||||
console.log(`Generating factors from ${files.length} TJA files...`);
|
||||
|
||||
const results: any[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const tja = fs.readFileSync(path.join(tjaDir, file), 'utf-8');
|
||||
const songno = path.basename(file, '.tja');
|
||||
const parsed = parseTja(tja);
|
||||
|
||||
const courses = [
|
||||
{ diff: 'oni', data: parsed?.oni },
|
||||
{ diff: 'ura', data: parsed?.edit }
|
||||
];
|
||||
|
||||
for (const course of courses) {
|
||||
if (course.data) {
|
||||
results.push({
|
||||
songno,
|
||||
difficulty: course.diff,
|
||||
factors: factorize(course.data)
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error processing ${file}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(factorsPath, JSON.stringify(results, null, 2), 'utf-8');
|
||||
console.log(`factors.json generated at ${factorsPath}`);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const { values } = parseArgs({
|
||||
dataDir: {
|
||||
type: "string"
|
||||
},
|
||||
trainScript: {
|
||||
script: {
|
||||
type: "string"
|
||||
},
|
||||
trainSize: {
|
||||
@@ -28,13 +28,13 @@ const { values } = parseArgs({
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values.dataDir || !values.workingDir || !values.trainScript) {
|
||||
if (!values.dataDir || !values.workingDir || !values.script) {
|
||||
console.error("--workingDir --dataDir --trainDir");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
generateFeatures();
|
||||
const child = spawn("python3", [values.trainScript,
|
||||
const child = spawn("python3", [values.script,
|
||||
"--workingDir", values.workingDir,
|
||||
"--dataDir", values.dataDir,
|
||||
"--trainSize", (Number(values.trainSize) || 500).toString(),
|
||||
50
script/visualize_abs_errors.py
Normal file
50
script/visualize_abs_errors.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
import json
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
import seaborn as sns
|
||||
|
||||
output_dir = 'output'
|
||||
model_dirs = [d for d in os.listdir(output_dir) if os.path.isdir(os.path.join(output_dir, d)) and os.path.exists(os.path.join(output_dir, d, 'compare.json'))]
|
||||
model_dirs.sort()
|
||||
|
||||
if not model_dirs:
|
||||
print("데이터를 찾을 수 없습니다.")
|
||||
exit()
|
||||
|
||||
fig, axes = plt.subplots(len(model_dirs), 1, figsize=(15, 6 * len(model_dirs)), sharex=False)
|
||||
if len(model_dirs) == 1:
|
||||
axes = [axes]
|
||||
|
||||
for i, model in enumerate(model_dirs):
|
||||
json_path = os.path.join(output_dir, model, 'compare.json')
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
df = pd.DataFrame(data.get('details', []))
|
||||
|
||||
# 에러 절댓값 계산 및 내림차순 정렬
|
||||
df['abs_error'] = df['error'].abs()
|
||||
df = df.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
|
||||
ax = axes[i]
|
||||
# y축에 abs_error를 사용하여 양수 영역만 표시
|
||||
sns.scatterplot(data=df, x=df.index, y='abs_error', ax=ax, alpha=0.6, s=20, color='darkorange')
|
||||
|
||||
ax.set_title(f'Model: {model} (Sorted by Absolute Error)', fontsize=15, fontweight='bold')
|
||||
ax.set_ylabel('Absolute Error (|Actual - Predicted|)', fontsize=12)
|
||||
ax.set_xlabel(f'Songs (Ordered by Error Magnitude)', fontsize=12)
|
||||
|
||||
# 가이드 라인 (오차 0.2, 0.5, 1.0 단위)
|
||||
ax.axhline(0.2, color='green', linestyle='--', linewidth=0.8, alpha=0.5, label='Target (0.2)')
|
||||
ax.axhline(0.5, color='blue', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
ax.axhline(1.0, color='red', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
|
||||
# y축을 0부터 시작하도록 설정
|
||||
ax.set_ylim(0, 3.5)
|
||||
ax.set_xticks([]) # x축 라벨 제거
|
||||
ax.grid(True, axis='y', alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
output_image = 'abs_error_analysis.png'
|
||||
plt.savefig(output_image)
|
||||
print(f"절댓값 에러 정렬 그래프가 {output_image}에 저장되었습니다.")
|
||||
152
train/factor/train_lightgbm.py
Normal file
152
train/factor/train_lightgbm.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import joblib
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import lightgbm as lgb
|
||||
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
|
||||
# =========================================================
|
||||
# Hyper Parameters
|
||||
# =========================================================
|
||||
|
||||
MAX_NOTES = 2000
|
||||
FACTOR_COUNT = 4
|
||||
INPUT_DIM = MAX_NOTES * FACTOR_COUNT
|
||||
|
||||
TRAIN_SIZE = 0
|
||||
VALID_SIZE = 0
|
||||
RANDOM_STATE = 42
|
||||
|
||||
PARAMS = {
|
||||
'objective': 'regression',
|
||||
'metric': 'mae',
|
||||
'verbosity': -1,
|
||||
'boosting_type': 'gbdt',
|
||||
'random_state': RANDOM_STATE,
|
||||
'learning_rate': 0.02,
|
||||
'num_leaves': 63,
|
||||
'n_estimators': 2000
|
||||
}
|
||||
|
||||
CONTINUE_TRAINING = True
|
||||
ERROR_TOLERANCE = 0.1
|
||||
|
||||
# =========================================================
|
||||
# 파일명
|
||||
# =========================================================
|
||||
|
||||
FACTORS_FILENAME = "factors.json"
|
||||
MEASURE_FILENAME = "measure.csv"
|
||||
MODEL_FILENAME = "model.pkl"
|
||||
SCALER_FILENAME = "scaler.pkl"
|
||||
|
||||
def safe_float(value):
|
||||
if value is None: return 0.0
|
||||
x = float(value)
|
||||
return x if math.isfinite(x) else 0.0
|
||||
|
||||
def train_model(working_dir: str, data_dir: str):
|
||||
random.seed(RANDOM_STATE)
|
||||
|
||||
factors_path = os.path.join(working_dir, FACTORS_FILENAME)
|
||||
measure_path = os.path.join(data_dir, MEASURE_FILENAME)
|
||||
model_path = os.path.join(working_dir, MODEL_FILENAME)
|
||||
scaler_path = os.path.join(working_dir, SCALER_FILENAME)
|
||||
|
||||
with open(factors_path, "r", encoding="utf-8") as f:
|
||||
factor_data = json.load(f)
|
||||
|
||||
feature_map = {(str(item["songno"]), str(item["difficulty"])): item["factors"] for item in factor_data}
|
||||
|
||||
dataset = []
|
||||
with open(measure_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader, None)
|
||||
for row in reader:
|
||||
if len(row) < 3: continue
|
||||
measure, songno, diff = safe_float(row[0]), str(row[1]), str(row[2])
|
||||
key = (songno, diff)
|
||||
|
||||
if key in feature_map:
|
||||
raw_factors = feature_map[key]
|
||||
vector = np.zeros(INPUT_DIM, dtype=np.float32)
|
||||
for i in range(min(len(raw_factors), MAX_NOTES)):
|
||||
for j in range(FACTOR_COUNT):
|
||||
vector[i * FACTOR_COUNT + j] = safe_float(raw_factors[i][j])
|
||||
dataset.append((vector, measure, songno, diff))
|
||||
|
||||
random.shuffle(dataset)
|
||||
if len(dataset) < (TRAIN_SIZE + VALID_SIZE):
|
||||
raise ValueError(f"Dataset size {len(dataset)} < required {TRAIN_SIZE + VALID_SIZE}")
|
||||
|
||||
train_ds = dataset[:TRAIN_SIZE]
|
||||
valid_ds = dataset[TRAIN_SIZE:TRAIN_SIZE + VALID_SIZE]
|
||||
|
||||
X_train = np.array([x for x, _, _, _ in train_ds])
|
||||
y_train = np.array([y for _, y, _, _ in train_ds])
|
||||
X_valid = np.array([x for x, _, _, _ in valid_ds])
|
||||
y_valid = np.array([y for _, y, _, _ in valid_ds])
|
||||
valid_info = [(s, d) for _, _, s, d in valid_ds]
|
||||
|
||||
if CONTINUE_TRAINING and os.path.exists(scaler_path):
|
||||
scaler = joblib.load(scaler_path)
|
||||
else:
|
||||
scaler = StandardScaler()
|
||||
scaler.fit(X_train)
|
||||
|
||||
X_train = scaler.transform(X_train)
|
||||
X_valid = scaler.transform(X_valid)
|
||||
|
||||
if CONTINUE_TRAINING and os.path.exists(model_path):
|
||||
model = joblib.load(model_path)
|
||||
model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)], init_model=model, callbacks=[lgb.early_stopping(stopping_rounds=100)])
|
||||
else:
|
||||
model = lgb.LGBMRegressor(**PARAMS)
|
||||
model.fit(X_train, y_train, eval_set=[(X_valid, y_valid)], callbacks=[lgb.early_stopping(stopping_rounds=100)])
|
||||
|
||||
pred = model.predict(X_valid)
|
||||
mae = mean_absolute_error(y_valid, pred)
|
||||
accuracy = np.sum(np.abs(pred - y_valid) <= ERROR_TOLERANCE) / len(y_valid)
|
||||
print(f"MAE: {mae:.4f} | Accuracy: {accuracy:.4f}")
|
||||
|
||||
# Results save
|
||||
validate_details = []
|
||||
for i in range(len(y_valid)):
|
||||
validate_details.append({"songno": valid_info[i][0], "diff": valid_info[i][1], "actual": float(y_valid[i]), "predicted": float(pred[i]), "error": float(y_valid[i] - pred[i])})
|
||||
|
||||
validate_details.sort(key=lambda x: abs(x["error"]), reverse=True)
|
||||
with open(os.path.join(working_dir, "validate.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"summary": {"mae": float(mae), "accuracy": float(accuracy)}, "details": validate_details}, f, indent=2)
|
||||
|
||||
# Plot
|
||||
plt.switch_backend('Agg')
|
||||
df_plot = pd.DataFrame(validate_details)
|
||||
df_plot['abs_error'] = df_plot['error'].abs()
|
||||
df_plot = df_plot.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
plt.figure(figsize=(12, 6))
|
||||
sns.scatterplot(data=df_plot, x=df_plot.index, y='abs_error', color='teal')
|
||||
plt.axhline(0.2, color='green', linestyle='--')
|
||||
plt.ylim(0, 4)
|
||||
plt.savefig(os.path.join(working_dir, "validate.png"))
|
||||
|
||||
joblib.dump(model, model_path)
|
||||
joblib.dump(scaler, scaler_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workingDir", required=True)
|
||||
parser.add_argument("--dataDir", required=True)
|
||||
parser.add_argument("--trainSize", required=True, type=int)
|
||||
parser.add_argument("--validSize", required=True, type=int)
|
||||
args = parser.parse_args()
|
||||
TRAIN_SIZE, VALID_SIZE = args.trainSize, args.validSize
|
||||
train_model(args.workingDir, args.dataDir)
|
||||
147
train/factor/train_xgboost.py
Normal file
147
train/factor/train_xgboost.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import joblib
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
|
||||
from xgboost import XGBRegressor
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
|
||||
# =========================================================
|
||||
# Hyper Parameters
|
||||
# =========================================================
|
||||
|
||||
MAX_NOTES = 2000 # 분석할 최대 노트 수
|
||||
FACTOR_COUNT = 4 # [type, bpm, scroll, delta]
|
||||
INPUT_DIM = MAX_NOTES * FACTOR_COUNT
|
||||
|
||||
TRAIN_SIZE = 0
|
||||
VALID_SIZE = 0
|
||||
RANDOM_STATE = 42
|
||||
|
||||
N_ESTIMATORS = 500
|
||||
MAX_DEPTH = 6
|
||||
LEARNING_RATE = 0.05
|
||||
CONTINUE_TRAINING = True
|
||||
ERROR_TOLERANCE = 0.1
|
||||
|
||||
# =========================================================
|
||||
# 파일명
|
||||
# =========================================================
|
||||
|
||||
FACTORS_FILENAME = "factors.json"
|
||||
MEASURE_FILENAME = "measure.csv"
|
||||
MODEL_FILENAME = "model.pkl"
|
||||
SCALER_FILENAME = "scaler.pkl"
|
||||
|
||||
def safe_float(value):
|
||||
if value is None: return 0.0
|
||||
x = float(value)
|
||||
return x if math.isfinite(x) else 0.0
|
||||
|
||||
def train_model(working_dir: str, data_dir: str):
|
||||
random.seed(RANDOM_STATE)
|
||||
|
||||
factors_path = os.path.join(working_dir, FACTORS_FILENAME)
|
||||
measure_path = os.path.join(data_dir, MEASURE_FILENAME)
|
||||
model_path = os.path.join(working_dir, MODEL_FILENAME)
|
||||
scaler_path = os.path.join(working_dir, SCALER_FILENAME)
|
||||
|
||||
with open(factors_path, "r", encoding="utf-8") as f:
|
||||
factor_data = json.load(f)
|
||||
|
||||
# feature_map build: key -> list of factors
|
||||
feature_map = {(str(item["songno"]), str(item["difficulty"])): item["factors"] for item in factor_data}
|
||||
|
||||
dataset = []
|
||||
with open(measure_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader, None)
|
||||
for row in reader:
|
||||
if len(row) < 3: continue
|
||||
measure, songno, diff = safe_float(row[0]), str(row[1]), str(row[2])
|
||||
key = (songno, diff)
|
||||
|
||||
if key in feature_map:
|
||||
raw_factors = feature_map[key]
|
||||
# 고정 길이 벡터로 변환 (Padding or Truncating)
|
||||
vector = np.zeros(INPUT_DIM, dtype=np.float32)
|
||||
for i in range(min(len(raw_factors), MAX_NOTES)):
|
||||
for j in range(FACTOR_COUNT):
|
||||
vector[i * FACTOR_COUNT + j] = safe_float(raw_factors[i][j])
|
||||
|
||||
dataset.append((vector, measure, songno, diff))
|
||||
|
||||
random.shuffle(dataset)
|
||||
if len(dataset) < (TRAIN_SIZE + VALID_SIZE):
|
||||
raise ValueError(f"Dataset size {len(dataset)} < required {TRAIN_SIZE + VALID_SIZE}")
|
||||
|
||||
train_ds = dataset[:TRAIN_SIZE]
|
||||
valid_ds = dataset[TRAIN_SIZE:TRAIN_SIZE + VALID_SIZE]
|
||||
|
||||
X_train = np.array([x for x, _, _, _ in train_ds])
|
||||
y_train = np.array([y for _, y, _, _ in train_ds])
|
||||
X_valid = np.array([x for x, _, _, _ in valid_ds])
|
||||
y_valid = np.array([y for _, y, _, _ in valid_ds])
|
||||
valid_info = [(s, d) for _, _, s, d in valid_ds]
|
||||
|
||||
if CONTINUE_TRAINING and os.path.exists(scaler_path):
|
||||
scaler = joblib.load(scaler_path)
|
||||
else:
|
||||
scaler = StandardScaler()
|
||||
scaler.fit(X_train)
|
||||
|
||||
X_train = scaler.transform(X_train)
|
||||
X_valid = scaler.transform(X_valid)
|
||||
|
||||
if CONTINUE_TRAINING and os.path.exists(model_path):
|
||||
model = joblib.load(model_path)
|
||||
model.fit(X_train, y_train, xgb_model=model.get_booster())
|
||||
else:
|
||||
model = XGBRegressor(n_estimators=N_ESTIMATORS, max_depth=MAX_DEPTH, learning_rate=LEARNING_RATE, objective="reg:squarederror", random_state=RANDOM_STATE)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
pred = model.predict(X_valid)
|
||||
mae = mean_absolute_error(y_valid, pred)
|
||||
accuracy = np.sum(np.abs(pred - y_valid) <= ERROR_TOLERANCE) / len(y_valid)
|
||||
print(f"MAE: {mae:.4f} | Accuracy: {accuracy:.4f}")
|
||||
|
||||
# Results save
|
||||
validate_details = []
|
||||
for i in range(len(y_valid)):
|
||||
validate_details.append({"songno": valid_info[i][0], "diff": valid_info[i][1], "actual": float(y_valid[i]), "predicted": float(pred[i]), "error": float(y_valid[i] - pred[i])})
|
||||
|
||||
validate_details.sort(key=lambda x: abs(x["error"]), reverse=True)
|
||||
with open(os.path.join(working_dir, "validate.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"summary": {"mae": float(mae), "accuracy": float(accuracy)}, "details": validate_details}, f, indent=2)
|
||||
|
||||
# Plot
|
||||
plt.switch_backend('Agg')
|
||||
df_plot = pd.DataFrame(validate_details)
|
||||
df_plot['abs_error'] = df_plot['error'].abs()
|
||||
df_plot = df_plot.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
plt.figure(figsize=(12, 6))
|
||||
sns.scatterplot(data=df_plot, x=df_plot.index, y='abs_error', color='crimson')
|
||||
plt.axhline(0.2, color='green', linestyle='--')
|
||||
plt.ylim(0, 4)
|
||||
plt.savefig(os.path.join(working_dir, "validate.png"))
|
||||
|
||||
joblib.dump(model, model_path)
|
||||
joblib.dump(scaler, scaler_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workingDir", required=True)
|
||||
parser.add_argument("--dataDir", required=True)
|
||||
parser.add_argument("--trainSize", required=True, type=int)
|
||||
parser.add_argument("--validSize", required=True, type=int)
|
||||
args = parser.parse_args()
|
||||
TRAIN_SIZE, VALID_SIZE = args.trainSize, args.validSize
|
||||
train_model(args.workingDir, args.dataDir)
|
||||
@@ -7,6 +7,9 @@ import random
|
||||
import joblib
|
||||
import numpy as np
|
||||
import lightgbm as lgb
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
|
||||
@@ -43,9 +46,9 @@ ERROR_TOLERANCE = 0.1
|
||||
|
||||
FEATURES_FILENAME = "features.json"
|
||||
MEASURE_FILENAME = "measure.csv"
|
||||
MODEL_FILENAME = "model_lgbm.pkl"
|
||||
SCALER_FILENAME = "scaler_lgbm.pkl"
|
||||
FEATURE_NAMES_FILENAME = "features_lgbm.txt"
|
||||
MODEL_FILENAME = "model.pkl"
|
||||
SCALER_FILENAME = "scaler.pkl"
|
||||
FEATURE_NAMES_FILENAME = "features.txt"
|
||||
|
||||
IGNORE_KEYS = {"songno", "difficulty"}
|
||||
|
||||
@@ -82,7 +85,7 @@ def train_model(working_dir: str, data_dir: str):
|
||||
key = (songno, diff)
|
||||
if key in feature_map:
|
||||
features = [safe_float(feature_map[key].get(k, 0)) for k in feature_names]
|
||||
dataset.append((features, measure))
|
||||
dataset.append((features, measure, songno, diff))
|
||||
|
||||
random.shuffle(dataset)
|
||||
if len(dataset) < (TRAIN_SIZE + VALID_SIZE):
|
||||
@@ -91,10 +94,11 @@ def train_model(working_dir: str, data_dir: str):
|
||||
train_dataset = dataset[:TRAIN_SIZE]
|
||||
valid_dataset = dataset[TRAIN_SIZE:TRAIN_SIZE + VALID_SIZE]
|
||||
|
||||
X_train = np.array([x for x, _ in train_dataset], dtype=np.float32)
|
||||
y_train = np.array([y for _, y in train_dataset], dtype=np.float32)
|
||||
X_valid = np.array([x for x, _ in valid_dataset], dtype=np.float32)
|
||||
y_valid = np.array([y for _, y in valid_dataset], dtype=np.float32)
|
||||
X_train = np.array([x for x, _, _, _ in train_dataset], dtype=np.float32)
|
||||
y_train = np.array([y for _, y, _, _ in train_dataset], dtype=np.float32)
|
||||
X_valid = np.array([x for x, _, _, _ in valid_dataset], dtype=np.float32)
|
||||
y_valid = np.array([y for _, y, _, _ in valid_dataset], dtype=np.float32)
|
||||
valid_info = [(s, d) for _, _, s, d in valid_dataset]
|
||||
|
||||
print(f"Train Size: {len(X_train)} | Valid Size: {len(X_valid)} | Features: {len(feature_names)}")
|
||||
|
||||
@@ -133,6 +137,66 @@ def train_model(working_dir: str, data_dir: str):
|
||||
|
||||
print(f"\nMAE: {mae:.4f} | Accuracy (±{ERROR_TOLERANCE}): {accuracy:.4f}")
|
||||
|
||||
# =====================================================
|
||||
# save validate.json
|
||||
# =====================================================
|
||||
validate_details = []
|
||||
for i in range(len(y_valid)):
|
||||
actual = float(y_valid[i])
|
||||
predicted = float(pred[i])
|
||||
songno, diff = valid_info[i]
|
||||
validate_details.append({
|
||||
"songno": songno,
|
||||
"diff": diff,
|
||||
"actual": actual,
|
||||
"predicted": predicted,
|
||||
"error": actual - predicted
|
||||
})
|
||||
|
||||
validate_details.sort(key=lambda x: abs(x["error"]), reverse=True)
|
||||
validate_result = {
|
||||
"summary": {
|
||||
"total_compared": len(y_valid),
|
||||
"average_absolute_error": float(mae),
|
||||
"accuracy": float(accuracy),
|
||||
"timestamp": "now",
|
||||
"script_used": "train/train_lightgbm.py"
|
||||
},
|
||||
"details": validate_details
|
||||
}
|
||||
|
||||
validate_path = os.path.join(working_dir, "validate.json")
|
||||
with open(validate_path, "w", encoding="utf-8") as f:
|
||||
json.dump(validate_result, f, indent=2, ensure_ascii=False)
|
||||
print(f"Validation result saved: {validate_path}")
|
||||
|
||||
# =====================================================
|
||||
# save validate.png
|
||||
# =====================================================
|
||||
try:
|
||||
plt.switch_backend('Agg')
|
||||
df_plot = pd.DataFrame(validate_details)
|
||||
df_plot['abs_error'] = df_plot['error'].abs()
|
||||
df_plot = df_plot.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
|
||||
plt.figure(figsize=(12, 6))
|
||||
sns.scatterplot(data=df_plot, x=df_plot.index, y='abs_error', alpha=0.6, s=20, color='darkorange')
|
||||
plt.axhline(0.2, color='green', linestyle='--', linewidth=0.8, alpha=0.5, label='Target (0.2)')
|
||||
plt.axhline(0.5, color='blue', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
plt.axhline(1.0, color='red', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
plt.ylim(0, max(3.5, df_plot['abs_error'].max() + 0.5))
|
||||
plt.title(f'Validation Absolute Error - {os.path.basename(working_dir)}', fontsize=14)
|
||||
plt.xlabel('Samples (Sorted by Error Magnitude)', fontsize=12)
|
||||
plt.ylabel('Absolute Error', fontsize=12)
|
||||
plt.grid(True, axis='y', alpha=0.3)
|
||||
|
||||
plot_path = os.path.join(working_dir, "validate.png")
|
||||
plt.savefig(plot_path)
|
||||
plt.close()
|
||||
print(f"Validation plot saved: {plot_path}")
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to create validation plot: {e}")
|
||||
|
||||
joblib.dump(model, model_path)
|
||||
joblib.dump(scaler, scaler_path)
|
||||
with open(feature_names_path, "w", encoding="utf-8") as f:
|
||||
@@ -6,6 +6,9 @@ import os
|
||||
import random
|
||||
import joblib
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
|
||||
from xgboost import XGBRegressor
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
@@ -177,7 +180,9 @@ def train_model(
|
||||
|
||||
dataset.append((
|
||||
features,
|
||||
measure
|
||||
measure,
|
||||
songno,
|
||||
diff
|
||||
))
|
||||
|
||||
# =====================================================
|
||||
@@ -205,25 +210,29 @@ def train_model(
|
||||
]
|
||||
|
||||
X_train = np.array(
|
||||
[x for x, _ in train_dataset],
|
||||
[x for x, _, _, _ in train_dataset],
|
||||
dtype=np.float32
|
||||
)
|
||||
|
||||
y_train = np.array(
|
||||
[y for _, y in train_dataset],
|
||||
[y for _, y, _, _ in train_dataset],
|
||||
dtype=np.float32
|
||||
)
|
||||
|
||||
X_valid = np.array(
|
||||
[x for x, _ in valid_dataset],
|
||||
[x for x, _, _, _ in valid_dataset],
|
||||
dtype=np.float32
|
||||
)
|
||||
|
||||
y_valid = np.array(
|
||||
[y for _, y in valid_dataset],
|
||||
[y for _, y, _, _ in valid_dataset],
|
||||
dtype=np.float32
|
||||
)
|
||||
|
||||
valid_info = [
|
||||
(s, d) for _, _, s, d in valid_dataset
|
||||
]
|
||||
|
||||
print(f"Train Size: {len(X_train)}")
|
||||
print(f"Valid Size: {len(X_valid)}")
|
||||
print(f"Feature Count: {len(feature_names)}")
|
||||
@@ -314,6 +323,73 @@ def train_model(
|
||||
for name, score in pairs:
|
||||
print(f"{name:25} {score:.6f}")
|
||||
|
||||
# =====================================================
|
||||
# save validate.json
|
||||
# =====================================================
|
||||
|
||||
validate_details = []
|
||||
for i in range(len(y_valid)):
|
||||
actual = float(y_valid[i])
|
||||
predicted = float(pred[i])
|
||||
songno, diff = valid_info[i]
|
||||
|
||||
validate_details.append({
|
||||
"songno": songno,
|
||||
"diff": diff,
|
||||
"actual": actual,
|
||||
"predicted": predicted,
|
||||
"error": actual - predicted
|
||||
})
|
||||
|
||||
# 에러 절댓값 기준 정렬
|
||||
validate_details.sort(key=lambda x: abs(x["error"]), reverse=True)
|
||||
|
||||
validate_result = {
|
||||
"summary": {
|
||||
"total_compared": len(y_valid),
|
||||
"average_absolute_error": float(mae),
|
||||
"accuracy": float(accuracy),
|
||||
"timestamp": "now",
|
||||
"script_used": "train/train_xgboost.py"
|
||||
},
|
||||
"details": validate_details
|
||||
}
|
||||
|
||||
validate_path = os.path.join(working_dir, "validate.json")
|
||||
with open(validate_path, "w", encoding="utf-8") as f:
|
||||
json.dump(validate_result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"Validation result saved: {validate_path}")
|
||||
|
||||
# =====================================================
|
||||
# save validate.png
|
||||
# =====================================================
|
||||
try:
|
||||
plt.switch_backend('Agg') # GUI 없는 환경 대응
|
||||
df_plot = pd.DataFrame(validate_details)
|
||||
df_plot['abs_error'] = df_plot['error'].abs()
|
||||
df_plot = df_plot.sort_values('abs_error', ascending=False).reset_index(drop=True)
|
||||
|
||||
plt.figure(figsize=(12, 6))
|
||||
sns.scatterplot(data=df_plot, x=df_plot.index, y='abs_error', alpha=0.6, s=20, color='darkorange')
|
||||
|
||||
plt.axhline(0.2, color='green', linestyle='--', linewidth=0.8, alpha=0.5, label='Target (0.2)')
|
||||
plt.axhline(0.5, color='blue', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
plt.axhline(1.0, color='red', linestyle='--', linewidth=0.8, alpha=0.5)
|
||||
|
||||
plt.ylim(0, max(3.5, df_plot['abs_error'].max() + 0.5))
|
||||
plt.title(f'Validation Absolute Error - {os.path.basename(working_dir)}', fontsize=14)
|
||||
plt.xlabel('Samples (Sorted by Error Magnitude)', fontsize=12)
|
||||
plt.ylabel('Absolute Error', fontsize=12)
|
||||
plt.grid(True, axis='y', alpha=0.3)
|
||||
|
||||
plot_path = os.path.join(working_dir, "validate.png")
|
||||
plt.savefig(plot_path)
|
||||
plt.close()
|
||||
print(f"Validation plot saved: {plot_path}")
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to create validation plot: {e}")
|
||||
|
||||
# =====================================================
|
||||
# save
|
||||
# =====================================================
|
||||
@@ -366,4 +442,4 @@ if __name__ == "__main__":
|
||||
train_model(
|
||||
args.workingDir,
|
||||
args.dataDir
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user