lightgbm
This commit is contained in:
@@ -1,21 +1,20 @@
|
|||||||
# fumen-analyze 최종 진행 리포트 (Final)
|
# fumen-analyze 프로젝트 상태 보고 (Current)
|
||||||
|
|
||||||
## 1. 모델 성능 요약
|
## 1. 모델 아키텍처 전환
|
||||||
- **최종 MAE**: **0.1758**
|
- **이전**: `GradientBoostingRegressor` (scikit-learn)
|
||||||
- **모델 유형**: `GradientBoostingRegressor` (Warm Start + Precision Tuning)
|
- **현재**: `XGBRegressor` (XGBoost)
|
||||||
- **최종 Estimators**: 1,600개
|
- **변경 사유**: 대규모 데이터셋에 대한 학습 속도 향상 및 하이퍼파라미터 튜닝의 유연성 확보.
|
||||||
- **성과**: 30개 이상의 피처를 기반으로 채보의 미세한 난이도 차이를 성공적으로 학습. 이상치(에러 > 1.0)를 배제한 정밀 튜닝으로 안정성 확보.
|
|
||||||
|
|
||||||
## 2. 핵심 피처 가중치 (추정)
|
## 2. 성능 지표 (목표치)
|
||||||
- **밀도**: Global NPS, Peak NPS (1s/2s)
|
- **목표 MAE**: **0.15 이하** (현재 약 0.17~0.18 추정)
|
||||||
- **리듬**: 엇박 비율(Triplets), 리듬 표준편차, 가속도
|
- **허용 오차 범위**: ±0.1 (상수 단위 기준)
|
||||||
- **패턴**: 손 배치 전환(Hand-switching), 3노트 단위 패턴 복잡도
|
- **성과**: 리듬 복잡도 및 순간 밀도 피처를 도입하여 10성급 고난이도 채보 간의 미세한 서열(상수 11.0~12.0)을 유의미하게 구분 중.
|
||||||
- **기믹**: SV/BPM 변화 빈도 및 변동성
|
|
||||||
|
|
||||||
## 3. 사용 안내
|
## 3. 핵심 업데이트 사항
|
||||||
- **학습 업데이트**: `./run_pipeline.sh` (현재 정밀 튜닝 모드 설정됨)
|
- **BPM/Scroll 변화 감지**: `#BPMCHANGE` 뿐만 아니라 `#SCROLL` 변화를 결합한 시각적 속도 변화 피처 추가.
|
||||||
- **상수 예측**: `./run_predict.sh <TJA_Path> [diff]`
|
- **색상 복잡도 가중치**: 노트 간 간격이 좁을수록 더 높은 난이도 가중치를 부여하는 $1/\Delta t^2$ 로직 적용.
|
||||||
- **문서 참조**: `GUIDE.md`
|
- **지속 학습 지원**: `CONTINUE_TRAINING` 옵션을 통해 기존 모델에 추가 데이터를 점진적으로 학습 가능.
|
||||||
|
|
||||||
## 4. 최종 결론
|
## 4. 향후 과제
|
||||||
현재 모델은 10성급 고난이도 곡들 사이의 미세한 '상수' 서열을 0.1~0.2 오차 범위 내에서 예측할 수 있는 수준에 도달했습니다. 추가 데이터 확보 시 0.1 미만으로의 진입이 충분히 가능합니다.
|
- `rhythm_complexity` 로직의 정교화 (비정형 박자 감지 강화).
|
||||||
|
- `measure.csv` 데이터셋 확충을 통한 과적합 방지 및 일반화 성능 향상.
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
# TJA 난이도 산정 핵심 요소 (Technical Factors)
|
# TJA 난이도 산정 피처 (Features)
|
||||||
|
|
||||||
학습 모델(DifficultyNet)에 입력되는 8가지 핵심 지표입니다. (spec.md 업데이트 반영)
|
`preprocess/featurize.ts`에서 추출되는 8가지 핵심 피처입니다.
|
||||||
|
|
||||||
## 1. 물리적 밀도
|
## 1. 양적 지표
|
||||||
- **density_avg**: 평균 노트 밀도 (총 노트 수 / 곡 길이)
|
- **note_count**: 총 노트 수. 곡의 길이에 따른 절대적인 노트 양.
|
||||||
- **density_peak**: 1초당 최대 밀도 (가장 촘촘한 구간의 노트 수)
|
|
||||||
|
|
||||||
## 2. BPM 및 속도 변화
|
## 2. 밀도 지표
|
||||||
- **bpm_avg**: 곡의 평균 BPM
|
- **density_avg**: 평균 NPS. `(총 노트 수 / 곡의 총 소요 시간) * 1000`.
|
||||||
- **bpm_var**: BPM 변화율 (BPM 변속의 다양성)
|
- **density_peak**: 순간 최대 밀도. 임의의 노트를 기준으로 1초 이내에 포함된 최대 노트 수.
|
||||||
|
|
||||||
## 3. 구조적 특징
|
## 3. 속도 변화 지표
|
||||||
- **note_count**: 총 노트 수
|
- **bpm_avg**: 노트별 BPM의 단순 산술 평균.
|
||||||
- **long_note_duration**: 전체 대비 긴 노트(지속형) 점유율
|
- **bpm_change**: BPM 변동 횟수. 인접한 두 노트 간 BPM 차이가 1.5 이상일 때 카운트.
|
||||||
|
- **scroll_change**: 시각적 속도(`BPM * Scroll`) 변동 횟수. 차이가 1.5 이상일 때 카운트.
|
||||||
|
|
||||||
## 4. 패턴 및 연타
|
## 4. 구조적 복잡도
|
||||||
- **complex_ratio**: 복합 패턴(큰 북/작은 북 혼합) 비율
|
- **rhythm_complexity**: 리듬의 정규성. 인접한 노트 간 시간 간격의 비율이 2의 거듭제곱(1, 0.5, 2 등)에 가까운 횟수를 카운트.
|
||||||
- **roll_count**: 연타(Roll) 노트의 총 개수
|
- *참고: 현재 코드는 정규 리듬일 때 증가하며, 문서와 실제 로직의 방향성이 반대일 수 있음.*
|
||||||
|
- **color_complexity**: 색상 배치 복잡도. 인접 노트의 색상(Don/Ka)이 바뀔 때, 그 간격의 제곱의 역수($1/\Delta t^2$)를 누적. 간격이 좁을수록 수치가 급격히 상승.
|
||||||
|
|
||||||
## 계산 로직
|
## 주의사항
|
||||||
- **밀도 계열**: 노트 간 시간 간격(`delta`)의 역수(`1/delta`)로 순시 밀도를 계산하여 `avg`/`peak` 도출.
|
- `color_complexity`는 수치의 범위가 매우 클 수 있으므로 학습 시 `StandardScaler`를 통한 정규화가 필수적입니다.
|
||||||
- **BPM 계열**: `#BPM` 및 `#STOP` 명령을 기반으로 시간 축 전개 후 평균 및 표준편차(`var`) 계산.
|
|
||||||
- **패턴 계열**: 슬라이딩 윈도우(4~8개 노트)에서 노트 타입 변화(`d↔k`) 횟수를 측정하여 비율 산정.
|
|
||||||
- **연타 계열**: `#ROLL` 명령 및 입력 밀집 구간 카운트.
|
|
||||||
|
|
||||||
## 중요 주의사항
|
|
||||||
- 모든 Factor는 모델 학습 전 `StandardScaler` 또는 `RobustScaler`를 통해 반드시 이상치를 처리해야 합니다.
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
## Technical Stack
|
## Technical Stack
|
||||||
- **Runtime**: [Bun](https://bun.sh/)
|
- **Runtime**: [Bun](https://bun.sh/)
|
||||||
- **Language**: TypeScript
|
- **Language**: TypeScript (Preprocessing), Python (Machine Learning)
|
||||||
- **Library**: [tja](https://www.npmjs.com/package/tja) (TJA parser)
|
- **ML Library**: [XGBoost](https://xgboost.readthedocs.io/), [scikit-learn](https://scikit-learn.org/)
|
||||||
|
- **TJA Parser**: [tja-parser](https://www.npmjs.com/package/tja-parser)
|
||||||
|
|
||||||
## Key Documents
|
## Key Directories
|
||||||
- `.gemini/tja-spec.md`: Rigorous TJA format specification.
|
- `preprocess/`: TJA 파싱 및 피처 추출 로직 (TypeScript)
|
||||||
- `tja-format.mediawiki`: Original source document.
|
- `script/`: 전처리, 학습 제어 스크립트
|
||||||
- `measure.csv`: Dataset with columns `상수`, `songno`, `diff`.
|
- `train/`: XGBoost 학습 엔진 (Python)
|
||||||
|
- `predict/`: 추론 엔진 (Python)
|
||||||
|
- `datas/tja/`: 원본 TJA 데이터셋
|
||||||
|
- `datas/measure.csv`: 정답지 (상수 데이터)
|
||||||
|
- `test/`: 학습 결과물 (model.pkl, scaler.pkl, features.json)
|
||||||
|
|
||||||
## Models (model/)
|
## Data Flow
|
||||||
- `constant_predictor.py`: DNN 기반 상수 예측 모델.
|
1. `datas/tja/*.tja` → `script/preprocess.ts` → `test/features.json`
|
||||||
|
2. `test/features.json` + `datas/measure.csv` → `train/train_xgboost.py` → `test/model.pkl`
|
||||||
## Library Usage (tja)
|
3. `test/model.pkl` + `test/features.json` → `predict/predict_xgboost.py` → Result
|
||||||
...
|
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
# 파이프라인 실행 규칙
|
# 파이프라인 실행 가이드
|
||||||
|
|
||||||
모든 경로는 실행 시점에 인자로 지정하여 관리합니다.
|
모든 실행은 프로젝트 루트 디렉토리에서 수행합니다.
|
||||||
|
|
||||||
## 1. 학습 파이프라인 (`run_train.sh`)
|
## 1. 전처리 (Feature Extraction)
|
||||||
- **인자 1 (TJA_DIR)**: 학습용 TJA 채보가 저장된 폴더 (예: `sample/training`)
|
TJA 파일들로부터 피처를 추출하여 `features.json`을 생성합니다.
|
||||||
- **인자 2 (MODEL_PATH)**: 모델이 저장될 경로 (예: `output/model/v2_constant`)
|
```bash
|
||||||
- **인자 3 (DATASET_DIR)**: 데이터셋이 저장될 폴더 (예: `output/dataset`)
|
bun run script/preprocess.ts --workingDir ./test --dataDir ./datas
|
||||||
|
```
|
||||||
|
- **--workingDir**: 결과물(`features.json`)이 저장될 폴더.
|
||||||
|
- **--dataDir**: 원본 데이터(`tja/` 폴더)가 위치한 폴더.
|
||||||
|
|
||||||
## 2. 예측 파이프라인 (`run_predict.sh`)
|
## 2. 모델 학습 (Training)
|
||||||
- **인자 1 (MODEL_PATH)**: 추론에 사용할 모델 경로
|
추출된 피처와 상수 데이터를 사용하여 XGBoost 모델을 학습시킵니다.
|
||||||
- **인자 2 (TJA_DIR)**: 예측할 TJA 채보가 모여있는 폴더
|
```bash
|
||||||
- **인자 3 (OUTPUT_DIR)**: 결과가 저장될 폴더
|
python3 train/train_xgboost.py \
|
||||||
|
--workingDir ./test \
|
||||||
|
--dataDir ./datas \
|
||||||
|
--trainSize 1000 \
|
||||||
|
--validSize 200
|
||||||
|
```
|
||||||
|
- **--trainSize / --validSize**: 학습 및 검증에 사용할 데이터 수.
|
||||||
|
|
||||||
|
## 3. 난이도 예측 (Inference)
|
||||||
|
특정 곡 번호(`songno`)에 대한 난이도 상수를 예측합니다.
|
||||||
|
```bash
|
||||||
|
python3 predict/predict_xgboost.py --workingDir ./test --songno 1000
|
||||||
|
```
|
||||||
|
- **--songno**: 예측할 곡의 번호 (파일명 기준).
|
||||||
|
|
||||||
|
## 주요 산출물 (in `workingDir`)
|
||||||
|
- `features.json`: 추출된 피처 데이터셋.
|
||||||
|
- `model.pkl`: 학습된 XGBoost 모델 파일.
|
||||||
|
- `scaler.pkl`: 피처 정규화를 위한 Scaler 객체.
|
||||||
|
- `features.txt`: 학습에 사용된 피처 이름 목록.
|
||||||
|
|||||||
56
docs/3-1.xgboost.md
Normal file
56
docs/3-1.xgboost.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# XGBoost 기반 난이도 상수 예측 모델
|
||||||
|
|
||||||
|
이 문서는 프로젝트의 핵심 학습 엔진인 XGBoost 모델의 구조, 하이퍼파라미터 및 파이프라인 과정을 설명합니다.
|
||||||
|
|
||||||
|
## 1. 모델 개요
|
||||||
|
본 프로젝트는 태고의 달인 채보의 미세한 난이도 차이(상수)를 예측하기 위해 **XGBoost (Extreme Gradient Boosting)** 회귀 모델을 사용합니다. 8가지 주요 피처를 입력으로 받아 0.1 단위의 정밀한 난이도 상수를 출력하는 것을 목표로 합니다.
|
||||||
|
|
||||||
|
## 2. 하이퍼파라미터 설정
|
||||||
|
`train/train_xgboost.py`에 정의된 주요 설정값은 다음과 같습니다.
|
||||||
|
|
||||||
|
| 파라미터 | 설정값 | 설명 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `N_ESTIMATORS` | 500 | 결정 트리의 개수 |
|
||||||
|
| `MAX_DEPTH` | 6 | 각 트리의 최대 깊이 |
|
||||||
|
| `LEARNING_RATE` | 0.05 | 학습률 (경사 하강 단계의 크기) |
|
||||||
|
| `SUBSAMPLE` | 0.8 | 각 트리 학습 시 사용할 데이터 샘플 비율 (과적합 방지) |
|
||||||
|
| `COLSAMPLE_BYTREE` | 0.8 | 각 트리 학습 시 사용할 피처 비율 |
|
||||||
|
| `RANDOM_STATE` | 42 | 결과 재현성을 위한 난수 시드 |
|
||||||
|
| `Objective` | `reg:squarederror` | 평균 제곱 오차 최소화를 목표로 함 |
|
||||||
|
|
||||||
|
## 3. 학습 프로세스 (Training Pipeline)
|
||||||
|
|
||||||
|
### A. 데이터 로드 및 매칭
|
||||||
|
- `features.json`: 전처리 단계에서 추출된 채보별 피처 데이터.
|
||||||
|
- `measure.csv`: 각 곡의 `songno`, `diff`에 대응하는 실제 난이도 상수(Label).
|
||||||
|
- 두 데이터를 `(songno, difficulty)` 키를 기준으로 매칭하여 학습 데이터셋을 구성합니다.
|
||||||
|
|
||||||
|
### B. 전처리 및 정규화
|
||||||
|
- **Feature Selection**: `songno`, `difficulty`를 제외한 모든 수치형 데이터를 피처로 사용합니다.
|
||||||
|
- **Scaling**: `sklearn.preprocessing.StandardScaler`를 사용하여 피처의 평균을 0, 분산을 1로 정규화합니다. 이는 `color_complexity`와 같이 수치 범위가 큰 피처가 모델에 과도한 영향을 주는 것을 방지합니다.
|
||||||
|
|
||||||
|
### C. 모델 학습 및 지속 학습 (Warm Start)
|
||||||
|
- `CONTINUE_TRAINING` 옵션이 활성화된 경우, 기존에 저장된 `model.pkl`과 `scaler.pkl`을 로드하여 이전 상태를 유지하며 추가 학습을 진행합니다.
|
||||||
|
|
||||||
|
## 4. 평가 지표
|
||||||
|
- **MAE (Mean Absolute Error)**: 실제 상수와 예측값 간의 절대 오차 평균.
|
||||||
|
- **Accuracy (±0.1)**: 예측값이 실제 값과 0.1 이내로 일치하는 비율을 측정하여 실질적인 예측 성능을 평가합니다.
|
||||||
|
- **Feature Importance**: 학습 완료 후 각 피처가 예측에 기여한 중요도를 출력하여 모델의 판단 근거를 확인합니다.
|
||||||
|
|
||||||
|
## 5. 예측 프로세스 (Inference)
|
||||||
|
`predict/predict_xgboost.py`를 통해 다음 과정을 거쳐 결과를 도출합니다.
|
||||||
|
|
||||||
|
1. 학습 시 저장된 `model.pkl`, `scaler.pkl`, `features.txt` 로드.
|
||||||
|
2. 입력받은 `songno`에 해당하는 피처를 `features.json`에서 탐색.
|
||||||
|
3. 학습 시와 동일한 순서로 피처 벡터를 구성하고 Scaler를 적용.
|
||||||
|
4. 모델을 통해 예측을 수행하고 결과를 JSON 배열 형태로 출력.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"songno": "1000",
|
||||||
|
"diff": "oni",
|
||||||
|
"predicted": 11.2345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
BIN
output/.DS_Store
vendored
Normal file
BIN
output/.DS_Store
vendored
Normal file
Binary file not shown.
9824
output/lightgbm/compare.json
Normal file
9824
output/lightgbm/compare.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
output/lightgbm/model_lgbm.pkl
Normal file
BIN
output/lightgbm/model_lgbm.pkl
Normal file
Binary file not shown.
17594
output/lightgbm/temp.json
Normal file
17594
output/lightgbm/temp.json
Normal file
File diff suppressed because it is too large
Load Diff
9824
output/lightgbm2/compare.json
Normal file
9824
output/lightgbm2/compare.json
Normal file
File diff suppressed because it is too large
Load Diff
17594
output/lightgbm2/features.json
Normal file
17594
output/lightgbm2/features.json
Normal file
File diff suppressed because it is too large
Load Diff
8
output/lightgbm2/features_lgbm.txt
Normal file
8
output/lightgbm2/features_lgbm.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bpm_avg
|
||||||
|
bpm_change
|
||||||
|
color_complexity
|
||||||
|
density_avg
|
||||||
|
density_peak
|
||||||
|
note_count
|
||||||
|
rhythm_complexity
|
||||||
|
scroll_change
|
||||||
BIN
output/lightgbm2/model_lgbm.pkl
Normal file
BIN
output/lightgbm2/model_lgbm.pkl
Normal file
Binary file not shown.
BIN
output/lightgbm2/scaler_lgbm.pkl
Normal file
BIN
output/lightgbm2/scaler_lgbm.pkl
Normal file
Binary file not shown.
17594
output/lightgbm2/temp.json
Normal file
17594
output/lightgbm2/temp.json
Normal file
File diff suppressed because it is too large
Load Diff
9824
output/lightgbm3/compare.json
Normal file
9824
output/lightgbm3/compare.json
Normal file
File diff suppressed because it is too large
Load Diff
17594
output/lightgbm3/features.json
Normal file
17594
output/lightgbm3/features.json
Normal file
File diff suppressed because it is too large
Load Diff
8
output/lightgbm3/features_lgbm.txt
Normal file
8
output/lightgbm3/features_lgbm.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bpm_avg
|
||||||
|
bpm_change
|
||||||
|
color_complexity
|
||||||
|
density_avg
|
||||||
|
density_peak
|
||||||
|
note_count
|
||||||
|
rhythm_complexity
|
||||||
|
scroll_change
|
||||||
BIN
output/lightgbm3/model_lgbm.pkl
Normal file
BIN
output/lightgbm3/model_lgbm.pkl
Normal file
Binary file not shown.
BIN
output/lightgbm3/scaler_lgbm.pkl
Normal file
BIN
output/lightgbm3/scaler_lgbm.pkl
Normal file
Binary file not shown.
17594
output/lightgbm3/temp.json
Normal file
17594
output/lightgbm3/temp.json
Normal file
File diff suppressed because it is too large
Load Diff
9824
output/xgboost/compare.json
Normal file
9824
output/xgboost/compare.json
Normal file
File diff suppressed because it is too large
Load Diff
17594
output/xgboost/features.json
Normal file
17594
output/xgboost/features.json
Normal file
File diff suppressed because it is too large
Load Diff
8
output/xgboost/features.txt
Normal file
8
output/xgboost/features.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bpm_avg
|
||||||
|
bpm_change
|
||||||
|
color_complexity
|
||||||
|
density_avg
|
||||||
|
density_peak
|
||||||
|
note_count
|
||||||
|
rhythm_complexity
|
||||||
|
scroll_change
|
||||||
BIN
output/xgboost/model.pkl
Normal file
BIN
output/xgboost/model.pkl
Normal file
Binary file not shown.
BIN
output/xgboost/scaler.pkl
Normal file
BIN
output/xgboost/scaler.pkl
Normal file
Binary file not shown.
17594
output/xgboost/temp.json
Normal file
17594
output/xgboost/temp.json
Normal file
File diff suppressed because it is too large
Load Diff
70
predict/predict_lightgbm.py
Normal file
70
predict/predict_lightgbm.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import joblib
|
||||||
|
import numpy as np
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
# 경고 무시 (Feature name 관련 경고 제거)
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 파일명
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
FEATURES_FILENAME = "features.json"
|
||||||
|
MODEL_FILENAME = "model_lgbm.pkl"
|
||||||
|
SCALER_FILENAME = "scaler_lgbm.pkl"
|
||||||
|
FEATURE_NAMES_FILENAME = "features_lgbm.txt"
|
||||||
|
|
||||||
|
def safe_float(value):
|
||||||
|
if value is None: return 0.0
|
||||||
|
x = float(value)
|
||||||
|
return x if math.isfinite(x) else 0.0
|
||||||
|
|
||||||
|
def predict(working_dir: str, songno: str, feature: str = None):
|
||||||
|
features_path = os.path.join(working_dir, FEATURES_FILENAME) if feature is None else feature
|
||||||
|
model_path = os.path.join(working_dir, MODEL_FILENAME)
|
||||||
|
scaler_path = os.path.join(working_dir, SCALER_FILENAME)
|
||||||
|
feature_names_path = os.path.join(working_dir, FEATURE_NAMES_FILENAME)
|
||||||
|
|
||||||
|
if not os.path.exists(model_path):
|
||||||
|
raise FileNotFoundError(f"Model not found at {model_path}")
|
||||||
|
|
||||||
|
model = joblib.load(model_path)
|
||||||
|
scaler = joblib.load(scaler_path)
|
||||||
|
|
||||||
|
with open(feature_names_path, "r", encoding="utf-8") as f:
|
||||||
|
feature_names = [line.strip() for line in f.readlines() if line.strip()]
|
||||||
|
|
||||||
|
with open(features_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
targets = [item for item in data if str(item["songno"]) == str(songno)]
|
||||||
|
if len(targets) == 0:
|
||||||
|
raise ValueError(f"Chart not found: songno={songno}")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for target in targets:
|
||||||
|
row = [safe_float(target.get(k, 0)) for k in feature_names]
|
||||||
|
X = np.array([row], dtype=np.float32)
|
||||||
|
X = scaler.transform(X)
|
||||||
|
pred = model.predict(X)[0]
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"songno": str(songno),
|
||||||
|
"diff": target.get("difficulty", "unknown"),
|
||||||
|
"predicted": round(float(pred), 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--workingDir", required=True)
|
||||||
|
parser.add_argument("--feature", required=False)
|
||||||
|
parser.add_argument("--songno", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
predict(args.workingDir, args.songno, args.feature)
|
||||||
@@ -109,7 +109,7 @@ def predict(
|
|||||||
# feature vector 생성
|
# feature vector 생성
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
||||||
row = []
|
results = []
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
row = []
|
row = []
|
||||||
@@ -124,12 +124,13 @@ def predict(
|
|||||||
|
|
||||||
pred = model.predict(X)[0]
|
pred = model.predict(X)[0]
|
||||||
|
|
||||||
diff = target.get("difficulty", "unknown")
|
results.append({
|
||||||
|
"songno": str(songno),
|
||||||
|
"diff": target.get("difficulty", "unknown"),
|
||||||
|
"predicted": round(float(pred), 4)
|
||||||
|
})
|
||||||
|
|
||||||
print(
|
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||||
f"{diff:10} "
|
|
||||||
f"{pred:.1f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|||||||
@@ -1,25 +1,148 @@
|
|||||||
import Bun from 'bun';
|
import Bun from 'bun';
|
||||||
|
import path from 'node:path';
|
||||||
import { parseArgs } from 'node:util';
|
import { parseArgs } from 'node:util';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
const { values } = parseArgs({
|
const { values } = parseArgs({
|
||||||
args: Bun.argv,
|
args: Bun.argv,
|
||||||
options: {
|
options: {
|
||||||
workingDir: {
|
workingDir: { type: "string" },
|
||||||
type: "string"
|
dataDir: { type: "string" },
|
||||||
},
|
script: { type: "string" },
|
||||||
tja: {
|
|
||||||
type: "string"
|
|
||||||
},
|
|
||||||
predictScript: {
|
|
||||||
type: "string"
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
strict: true,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!values.tja || !values.workingDir || !values.predictScript) {
|
if (!values.workingDir || !values.dataDir || !values.script) {
|
||||||
console.error("--workingDir --dataDir --trainDir");
|
console.error("Usage: bun run script/compare.ts --workingDir <dir> --dataDir <dir> --script <python_script>");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const songno = "temp";
|
const workingDir = values.workingDir;
|
||||||
|
const dataDir = values.dataDir;
|
||||||
|
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...");
|
||||||
|
const preprocessResult = Bun.spawnSync([
|
||||||
|
"bun", "run", "script/preprocess.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 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...`);
|
||||||
|
const comparisonResults: any[] = [];
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
for (const songno of uniqueSongnos) {
|
||||||
|
try {
|
||||||
|
const predictProcess = Bun.spawnSync([
|
||||||
|
"python3", predictScript,
|
||||||
|
"--workingDir", workingDir,
|
||||||
|
"--songno", songno as string,
|
||||||
|
"--feature", tempFilePath
|
||||||
|
]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
},
|
||||||
|
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!`);
|
||||||
|
console.log(`Total compared: ${comparisonResults.length}`);
|
||||||
|
console.log(`Average Error: ${avgError.toFixed(4)}`);
|
||||||
|
console.log(`Results saved to: ${comparePath}`);
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ const { values } = parseArgs({
|
|||||||
},
|
},
|
||||||
dataDir: {
|
dataDir: {
|
||||||
type: "string"
|
type: "string"
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
strict: true,
|
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,5 +62,5 @@ for (const file of files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const featurePath = path.join(workingDir, 'features.json');
|
const featurePath = path.join(workingDir, values.fileName ?? 'features.json');
|
||||||
fs.writeFileSync(featurePath, JSON.stringify(features, null, 2), 'utf-8');
|
fs.writeFileSync(featurePath, JSON.stringify(features, null, 2), 'utf-8');
|
||||||
BIN
test/model.pkl
BIN
test/model.pkl
Binary file not shown.
152
train/train_lightgbm.py
Normal file
152
train/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 lightgbm as lgb
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
from sklearn.metrics import mean_absolute_error
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Hyper Parameters
|
||||||
|
# =========================================================
|
||||||
|
|
||||||
|
TRAIN_SIZE = 0
|
||||||
|
VALID_SIZE = 0
|
||||||
|
|
||||||
|
RANDOM_STATE = 42
|
||||||
|
|
||||||
|
# LightGBM 특정 하이퍼파라미터
|
||||||
|
PARAMS = {
|
||||||
|
'objective': 'regression',
|
||||||
|
'metric': 'mae',
|
||||||
|
'verbosity': -1,
|
||||||
|
'boosting_type': 'gbdt',
|
||||||
|
'random_state': RANDOM_STATE,
|
||||||
|
'learning_rate': 0.02, # 더 정밀한 학습을 위해 하향
|
||||||
|
'num_leaves': 63, # 더 복잡한 패턴 학습을 위해 상향
|
||||||
|
'feature_fraction': 0.9,
|
||||||
|
'bagging_fraction': 0.8,
|
||||||
|
'bagging_freq': 5,
|
||||||
|
'n_estimators': 3000 # 학습량 대폭 상향
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTINUE_TRAINING = True
|
||||||
|
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"
|
||||||
|
|
||||||
|
IGNORE_KEYS = {"songno", "difficulty"}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
features_path = os.path.join(working_dir, FEATURES_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)
|
||||||
|
feature_names_path = os.path.join(working_dir, FEATURE_NAMES_FILENAME)
|
||||||
|
|
||||||
|
with open(features_path, "r", encoding="utf-8") as f:
|
||||||
|
feature_data = json.load(f)
|
||||||
|
|
||||||
|
if len(feature_data) == 0:
|
||||||
|
raise ValueError("features.json is empty")
|
||||||
|
|
||||||
|
feature_map = {(str(item["songno"]), str(item["difficulty"])): item for item in feature_data}
|
||||||
|
feature_names = sorted([k for k in feature_data[0].keys() if k not in IGNORE_KEYS])
|
||||||
|
|
||||||
|
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:
|
||||||
|
features = [safe_float(feature_map[key].get(k, 0)) for k in feature_names]
|
||||||
|
dataset.append((features, measure))
|
||||||
|
|
||||||
|
random.shuffle(dataset)
|
||||||
|
if len(dataset) < (TRAIN_SIZE + VALID_SIZE):
|
||||||
|
raise ValueError(f"Not enough dataset ({len(dataset)} < {TRAIN_SIZE + VALID_SIZE})")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
print(f"Train Size: {len(X_train)} | Valid Size: {len(X_valid)} | Features: {len(feature_names)}")
|
||||||
|
|
||||||
|
if CONTINUE_TRAINING and os.path.exists(scaler_path):
|
||||||
|
print("Loading existing scaler...")
|
||||||
|
scaler = joblib.load(scaler_path)
|
||||||
|
else:
|
||||||
|
print("Creating new scaler...")
|
||||||
|
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):
|
||||||
|
print("Loading existing model for incremental training...")
|
||||||
|
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:
|
||||||
|
print("Creating new LightGBM model...")
|
||||||
|
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"\nMAE: {mae:.4f} | Accuracy (±{ERROR_TOLERANCE}): {accuracy:.4f}")
|
||||||
|
|
||||||
|
joblib.dump(model, model_path)
|
||||||
|
joblib.dump(scaler, scaler_path)
|
||||||
|
with open(feature_names_path, "w", encoding="utf-8") as f:
|
||||||
|
for name in feature_names: f.write(name + "\n")
|
||||||
|
|
||||||
|
print(f"\nSaved to {working_dir}: {MODEL_FILENAME}, {SCALER_FILENAME}")
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import joblib
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from xgboost import XGBRegressor
|
|
||||||
from sklearn.model_selection import train_test_split
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
from sklearn.metrics import mean_absolute_error
|
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# Hyper Parameters
|
|
||||||
# =========================================================
|
|
||||||
|
|
||||||
TEST_SIZE = 0.2
|
|
||||||
RANDOM_STATE = 42
|
|
||||||
|
|
||||||
N_ESTIMATORS = 500
|
|
||||||
MAX_DEPTH = 6
|
|
||||||
LEARNING_RATE = 0.05
|
|
||||||
SUBSAMPLE = 0.8
|
|
||||||
COLSAMPLE_BYTREE = 0.8
|
|
||||||
|
|
||||||
CONTINUE_TRAINING = True
|
|
||||||
|
|
||||||
# 예측 성공으로 간주할 허용 오차
|
|
||||||
ERROR_TOLERANCE = 0.1
|
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# 파일명
|
|
||||||
# =========================================================
|
|
||||||
|
|
||||||
FEATURES_FILENAME = "features.json"
|
|
||||||
MEASURE_FILENAME = "measure.csv"
|
|
||||||
|
|
||||||
MODEL_FILENAME = "model.pkl"
|
|
||||||
SCALER_FILENAME = "scaler.pkl"
|
|
||||||
FEATURE_NAMES_FILENAME = "features.txt"
|
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# 무시할 key
|
|
||||||
# =========================================================
|
|
||||||
|
|
||||||
IGNORE_KEYS = {
|
|
||||||
"songno",
|
|
||||||
"difficulty"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def safe_float(value):
|
|
||||||
if value is None:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
x = float(value)
|
|
||||||
|
|
||||||
if not math.isfinite(x):
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
return x
|
|
||||||
|
|
||||||
|
|
||||||
def train_model(
|
|
||||||
working_dir: str,
|
|
||||||
data_dir: str
|
|
||||||
):
|
|
||||||
# =====================================================
|
|
||||||
# path
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
features_path = os.path.join(
|
|
||||||
working_dir,
|
|
||||||
FEATURES_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
|
|
||||||
)
|
|
||||||
|
|
||||||
feature_names_path = os.path.join(
|
|
||||||
working_dir,
|
|
||||||
FEATURE_NAMES_FILENAME
|
|
||||||
)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# features.json
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
with open(features_path, "r", encoding="utf-8") as f:
|
|
||||||
feature_data = json.load(f)
|
|
||||||
|
|
||||||
if len(feature_data) == 0:
|
|
||||||
raise ValueError("features.json is empty")
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# feature map
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
feature_map = {}
|
|
||||||
|
|
||||||
for item in feature_data:
|
|
||||||
key = (
|
|
||||||
str(item["songno"]),
|
|
||||||
str(item["difficulty"])
|
|
||||||
)
|
|
||||||
|
|
||||||
feature_map[key] = item
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# feature names
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
feature_names = sorted([
|
|
||||||
k for k in feature_data[0].keys()
|
|
||||||
if k not in IGNORE_KEYS
|
|
||||||
])
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# measure.csv
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
X = []
|
|
||||||
y = []
|
|
||||||
|
|
||||||
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 = safe_float(row[0])
|
|
||||||
songno = str(row[1])
|
|
||||||
diff = str(row[2])
|
|
||||||
|
|
||||||
key = (songno, diff)
|
|
||||||
|
|
||||||
if key not in feature_map:
|
|
||||||
print(
|
|
||||||
f"[WARN] feature not found: "
|
|
||||||
f"{songno} {diff}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
feature_item = feature_map[key]
|
|
||||||
|
|
||||||
features = [
|
|
||||||
safe_float(feature_item.get(k, 0))
|
|
||||||
for k in feature_names
|
|
||||||
]
|
|
||||||
|
|
||||||
X.append(features)
|
|
||||||
y.append(measure)
|
|
||||||
|
|
||||||
if len(X) == 0:
|
|
||||||
raise ValueError("No training data")
|
|
||||||
|
|
||||||
X = np.array(X, dtype=np.float32)
|
|
||||||
y = np.array(y, dtype=np.float32)
|
|
||||||
|
|
||||||
print(f"Dataset Size: {len(X)}")
|
|
||||||
print(f"Feature Count: {len(feature_names)}")
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# split
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
X_train, X_valid, y_train, y_valid = train_test_split(
|
|
||||||
X,
|
|
||||||
y,
|
|
||||||
test_size=TEST_SIZE,
|
|
||||||
random_state=RANDOM_STATE
|
|
||||||
)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# scaler
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
if CONTINUE_TRAINING and os.path.exists(scaler_path):
|
|
||||||
print("Loading existing scaler...")
|
|
||||||
|
|
||||||
scaler = joblib.load(scaler_path)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("Creating new scaler...")
|
|
||||||
|
|
||||||
scaler = StandardScaler()
|
|
||||||
scaler.fit(X_train)
|
|
||||||
|
|
||||||
X_train = scaler.transform(X_train)
|
|
||||||
X_valid = scaler.transform(X_valid)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# model
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
if CONTINUE_TRAINING and os.path.exists(model_path):
|
|
||||||
print("Loading existing model...")
|
|
||||||
|
|
||||||
model = joblib.load(model_path)
|
|
||||||
|
|
||||||
previous_booster = model.get_booster()
|
|
||||||
|
|
||||||
model.fit(
|
|
||||||
X_train,
|
|
||||||
y_train,
|
|
||||||
xgb_model=previous_booster
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("Creating new model...")
|
|
||||||
|
|
||||||
model = XGBRegressor(
|
|
||||||
n_estimators=N_ESTIMATORS,
|
|
||||||
max_depth=MAX_DEPTH,
|
|
||||||
learning_rate=LEARNING_RATE,
|
|
||||||
subsample=SUBSAMPLE,
|
|
||||||
colsample_bytree=COLSAMPLE_BYTREE,
|
|
||||||
objective="reg:squarederror",
|
|
||||||
random_state=RANDOM_STATE
|
|
||||||
)
|
|
||||||
|
|
||||||
model.fit(X_train, y_train)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# evaluate
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
pred = model.predict(X_valid)
|
|
||||||
|
|
||||||
mae = mean_absolute_error(y_valid, pred)
|
|
||||||
|
|
||||||
correct = np.sum(
|
|
||||||
np.abs(pred - y_valid) <= ERROR_TOLERANCE
|
|
||||||
)
|
|
||||||
|
|
||||||
accuracy = correct / len(y_valid)
|
|
||||||
|
|
||||||
print(f"\nMAE: {mae:.4f}")
|
|
||||||
print(
|
|
||||||
f"Accuracy "
|
|
||||||
f"(±{ERROR_TOLERANCE}): "
|
|
||||||
f"{accuracy:.4f}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# feature importance
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
print("\nFeature Importance:")
|
|
||||||
|
|
||||||
importance = model.feature_importances_
|
|
||||||
|
|
||||||
pairs = list(zip(feature_names, importance))
|
|
||||||
pairs.sort(key=lambda x: x[1], reverse=True)
|
|
||||||
|
|
||||||
for name, score in pairs:
|
|
||||||
print(f"{name:25} {score:.6f}")
|
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# save
|
|
||||||
# =====================================================
|
|
||||||
|
|
||||||
joblib.dump(model, model_path)
|
|
||||||
joblib.dump(scaler, scaler_path)
|
|
||||||
|
|
||||||
with open(feature_names_path, "w", encoding="utf-8") as f:
|
|
||||||
for name in feature_names:
|
|
||||||
f.write(name + "\n")
|
|
||||||
|
|
||||||
print("\nSaved:")
|
|
||||||
print(model_path)
|
|
||||||
print(scaler_path)
|
|
||||||
print(feature_names_path)
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# main
|
|
||||||
# =========================================================
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--workingDir",
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--dataDir",
|
|
||||||
required=True
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
train_model(
|
|
||||||
args.workingDir,
|
|
||||||
args.dataDir
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user