Compare commits

...

2 Commits

Author SHA1 Message Date
9962544bf5 image 2026-04-25 17:57:19 +09:00
956c53ba23 docs 2026-04-25 16:51:07 +09:00
55 changed files with 11665894 additions and 99015 deletions

BIN
.DS_Store vendored

Binary file not shown.

View 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`)를 통한 모델 약점 보완.

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

41
docs/3-2.lightgbm.md Normal file
View 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
View 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

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

5817386
output/xgboost_factor/temp.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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()

View 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
View 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());
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View 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);
}

View 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);
}

View 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
View 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}`);
}

View File

@@ -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(),

View 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}에 저장되었습니다.")

View 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)

View 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)

View File

@@ -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:

View File

@@ -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
)
)