This commit is contained in:
2026-04-25 15:50:38 +09:00
parent da4201fb20
commit df82515a33
37 changed files with 162982 additions and 393 deletions

View File

@@ -1,21 +1,20 @@
# fumen-analyze 최종 진행 리포트 (Final)
# fumen-analyze 프로젝트 상태 보고 (Current)
## 1. 모델 성능 요약
- **최종 MAE**: **0.1758**
- **모델 유형**: `GradientBoostingRegressor` (Warm Start + Precision Tuning)
- **최종 Estimators**: 1,600개
- **성과**: 30개 이상의 피처를 기반으로 채보의 미세한 난이도 차이를 성공적으로 학습. 이상치(에러 > 1.0)를 배제한 정밀 튜닝으로 안정성 확보.
## 1. 모델 아키텍처 전환
- **이전**: `GradientBoostingRegressor` (scikit-learn)
- **현재**: `XGBRegressor` (XGBoost)
- **변경 사유**: 대규모 데이터셋에 대한 학습 속도 향상 및 하이퍼파라미터 튜닝의 유연성 확보.
## 2. 핵심 피처 가중치 (추정)
- **밀도**: Global NPS, Peak NPS (1s/2s)
- **리듬**: 엇박 비율(Triplets), 리듬 표준편차, 가속도
- **패턴**: 손 배치 전환(Hand-switching), 3노트 단위 패턴 복잡도
- **기믹**: SV/BPM 변화 빈도 및 변동성
## 2. 성능 지표 (목표치)
- **목표 MAE**: **0.15 이하** (현재 약 0.17~0.18 추정)
- **허용 오차 범위**: ±0.1 (상수 단위 기준)
- **성과**: 리듬 복잡도 및 순간 밀도 피처를 도입하여 10성급 고난이도 채보 간의 미세한 서열(상수 11.0~12.0)을 유의미하게 구분 중.
## 3. 사용 안내
- **학습 업데이트**: `./run_pipeline.sh` (현재 정밀 튜닝 모드 설정됨)
- **상수 예측**: `./run_predict.sh <TJA_Path> [diff]`
- **문서 참조**: `GUIDE.md`
## 3. 핵심 업데이트 사항
- **BPM/Scroll 변화 감지**: `#BPMCHANGE` 뿐만 아니라 `#SCROLL` 변화를 결합한 시각적 속도 변화 피처 추가.
- **색상 복잡도 가중치**: 노트 간 간격이 좁을수록 더 높은 난이도 가중치를 부여하는 $1/\Delta t^2$ 로직 적용.
- **지속 학습 지원**: `CONTINUE_TRAINING` 옵션을 통해 기존 모델에 추가 데이터를 점진적으로 학습 가능.
## 4. 최종 결론
현재 모델은 10성급 고난이도 곡들 사이의 미세한 '상수' 서열을 0.1~0.2 오차 범위 내에서 예측할 수 있는 수준에 도달했습니다. 추가 데이터 확보 시 0.1 미만으로의 진입이 충분히 가능합니다.
## 4. 향후 과제
- `rhythm_complexity` 로직의 정교화 (비정형 박자 감지 강화).
- `measure.csv` 데이터셋 확충을 통한 과적합 방지 및 일반화 성능 향상.

View File

@@ -1,28 +1,23 @@
# TJA 난이도 산정 핵심 요소 (Technical Factors)
# TJA 난이도 산정 피처 (Features)
학습 모델(DifficultyNet)에 입력되는 8가지 핵심 지표입니다. (spec.md 업데이트 반영)
`preprocess/featurize.ts`에서 추출되는 8가지 핵심 피처입니다.
## 1. 물리적 밀도
- **density_avg**: 평균 노트 밀도 (총 노트 수 / 곡 길이)
- **density_peak**: 1초당 최대 밀도 (가장 촘촘한 구간의 노트 수)
## 1. 양적 지표
- **note_count**: 총 노트 수. 길이에 따른 절대적인 노트 양.
## 2. BPM 및 속도 변화
- **bpm_avg**: 곡의 평균 BPM
- **bpm_var**: BPM 변화율 (BPM 변속의 다양성)
## 2. 밀도 지표
- **density_avg**: 평균 NPS. `(총 노트 수 / 곡의 총 소요 시간) * 1000`.
- **density_peak**: 순간 최대 밀도. 임의의 노트를 기준으로 1초 이내에 포함된 최대 노트 수.
## 3. 구조적 특징
- **note_count**: 노트
- **long_note_duration**: 전체 대비 긴 노트(지속형) 점유율
## 3. 속도 변화 지표
- **bpm_avg**: 노트별 BPM의 단순 산술 평균.
- **bpm_change**: BPM 변동 횟수. 인접한 두 노트 간 BPM 차이가 1.5 이상일 때 카운트.
- **scroll_change**: 시각적 속도(`BPM * Scroll`) 변동 횟수. 차이가 1.5 이상일 때 카운트.
## 4. 패턴 및 연타
- **complex_ratio**: 복합 패턴(큰 북/작은 북 혼합) 비율
- **roll_count**: 연타(Roll) 노트의 총 개수
## 4. 구조적 복잡도
- **rhythm_complexity**: 리듬의 정규성. 인접한 노트 간 시간 간격의 비율이 2의 거듭제곱(1, 0.5, 2 등)에 가까운 횟수를 카운트.
- *참고: 현재 코드는 정규 리듬일 때 증가하며, 문서와 실제 로직의 방향성이 반대일 수 있음.*
- **color_complexity**: 색상 배치 복잡도. 인접 노트의 색상(Don/Ka)이 바뀔 때, 그 간격의 제곱의 역수($1/\Delta t^2$)를 누적. 간격이 좁을수록 수치가 급격히 상승.
## 계산 로직
- **밀도 계열**: 노트 간 시간 간격(`delta`)의 역수(`1/delta`)로 순시 밀도를 계산하여 `avg`/`peak` 도출.
- **BPM 계열**: `#BPM``#STOP` 명령을 기반으로 시간 축 전개 후 평균 및 표준편차(`var`) 계산.
- **패턴 계열**: 슬라이딩 윈도우(4~8개 노트)에서 노트 타입 변화(`d↔k`) 횟수를 측정하여 비율 산정.
- **연타 계열**: `#ROLL` 명령 및 입력 밀집 구간 카운트.
## 중요 주의사항
- 모든 Factor는 모델 학습 전 `StandardScaler` 또는 `RobustScaler`를 통해 반드시 이상치를 처리해야 합니다.
## 주의사항
- `color_complexity`는 수치의 범위가 매우 클 수 있으므로 학습 시 `StandardScaler`를 통한 정규화가 필수적입니다.

View File

@@ -2,16 +2,20 @@
## Technical Stack
- **Runtime**: [Bun](https://bun.sh/)
- **Language**: TypeScript
- **Library**: [tja](https://www.npmjs.com/package/tja) (TJA parser)
- **Language**: TypeScript (Preprocessing), Python (Machine Learning)
- **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
- `.gemini/tja-spec.md`: Rigorous TJA format specification.
- `tja-format.mediawiki`: Original source document.
- `measure.csv`: Dataset with columns `상수`, `songno`, `diff`.
## Key Directories
- `preprocess/`: TJA 파싱 및 피처 추출 로직 (TypeScript)
- `script/`: 전처리, 학습 제어 스크립트
- `train/`: XGBoost 학습 엔진 (Python)
- `predict/`: 추론 엔진 (Python)
- `datas/tja/`: 원본 TJA 데이터셋
- `datas/measure.csv`: 정답지 (상수 데이터)
- `test/`: 학습 결과물 (model.pkl, scaler.pkl, features.json)
## Models (model/)
- `constant_predictor.py`: DNN 기반 상수 예측 모델.
## Library Usage (tja)
...
## 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

View File

@@ -1,13 +1,35 @@
# 파이프라인 실행 규칙
# 파이프라인 실행 가이드
모든 경로는 실행 시점에 인자로 지정하여 관리합니다.
모든 실행은 프로젝트 루트 디렉토리에서 수행합니다.
## 1. 학습 파이프라인 (`run_train.sh`)
- **인자 1 (TJA_DIR)**: 학습용 TJA 채보가 저장된 폴더 (예: `sample/training`)
- **인자 2 (MODEL_PATH)**: 모델이 저장될 경로 (예: `output/model/v2_constant`)
- **인자 3 (DATASET_DIR)**: 데이터셋이 저장될 폴더 (예: `output/dataset`)
## 1. 전처리 (Feature Extraction)
TJA 파일들로부터 피처를 추출하여 `features.json`을 생성합니다.
```bash
bun run script/preprocess.ts --workingDir ./test --dataDir ./datas
```
- **--workingDir**: 결과물(`features.json`)이 저장될 폴더.
- **--dataDir**: 원본 데이터(`tja/` 폴더)가 위치한 폴더.
## 2. 예측 파이프라인 (`run_predict.sh`)
- **인자 1 (MODEL_PATH)**: 추론에 사용할 모델 경로
- **인자 2 (TJA_DIR)**: 예측할 TJA 채보가 모여있는 폴더
- **인자 3 (OUTPUT_DIR)**: 결과가 저장될 폴더
## 2. 모델 학습 (Training)
추출된 피처와 상수 데이터를 사용하여 XGBoost 모델을 학습시킵니다.
```bash
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
View 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

Binary file not shown.

9824
output/lightgbm/compare.json Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

17594
output/lightgbm/temp.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17594
output/lightgbm2/features.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
bpm_avg
bpm_change
color_complexity
density_avg
density_peak
note_count
rhythm_complexity
scroll_change

Binary file not shown.

Binary file not shown.

17594
output/lightgbm2/temp.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17594
output/lightgbm3/features.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
bpm_avg
bpm_change
color_complexity
density_avg
density_peak
note_count
rhythm_complexity
scroll_change

Binary file not shown.

Binary file not shown.

17594
output/lightgbm3/temp.json Normal file

File diff suppressed because it is too large Load Diff

9824
output/xgboost/compare.json Normal file

File diff suppressed because it is too large Load Diff

17594
output/xgboost/features.json Normal file

File diff suppressed because it is too large Load Diff

View 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

Binary file not shown.

BIN
output/xgboost/scaler.pkl Normal file

Binary file not shown.

17594
output/xgboost/temp.json Normal file

File diff suppressed because it is too large Load Diff

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

View File

@@ -109,7 +109,7 @@ def predict(
# feature vector 생성
# =====================================================
row = []
results = []
for target in targets:
row = []
@@ -124,12 +124,13 @@ def predict(
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(
f"{diff:10} "
f"{pred:.1f}"
)
print(json.dumps(results, indent=2, ensure_ascii=False))
# =========================================================

View File

@@ -1,25 +1,148 @@
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"
},
tja: {
type: "string"
},
predictScript: {
type: "string"
},
workingDir: { type: "string" },
dataDir: { type: "string" },
script: { type: "string" },
},
strict: true,
allowPositionals: true,
});
if (!values.tja || !values.workingDir || !values.predictScript) {
console.error("--workingDir --dataDir --trainDir");
if (!values.workingDir || !values.dataDir || !values.script) {
console.error("Usage: bun run script/compare.ts --workingDir <dir> --dataDir <dir> --script <python_script>");
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}`);

View File

@@ -13,9 +13,11 @@ const { values } = parseArgs({
},
dataDir: {
type: "string"
},
fileName: {
type: "string"
}
},
strict: 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');

Binary file not shown.

152
train/train_lightgbm.py Normal file
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 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)

View File

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