.
This commit is contained in:
42
script/extract.ts
Normal file
42
script/extract.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { parseTja } from './parse';
|
||||
import { factorize } from './factorize';
|
||||
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import iconv from 'iconv-lite';
|
||||
|
||||
const [,, workingDir, dataDir] = process.argv;
|
||||
|
||||
if (existsSync(join(workingDir, 'features.json'))) {
|
||||
console.log('features.json already exists. Skipping extraction.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tjaDir = join(dataDir, 'tja');
|
||||
const results: any[] = [];
|
||||
|
||||
for (const file of readdirSync(tjaDir)) {
|
||||
if (!file.endsWith('.tja')) continue;
|
||||
|
||||
const songno = file.replace(/\D/g, '');
|
||||
const buffer = readFileSync(join(tjaDir, file));
|
||||
|
||||
let content = iconv.decode(buffer, 'shift-jis', { stripBOM: true });
|
||||
content = content.replace(/\uFFFD/g, '').replace(/[\u{0080}-\u{FFFF}]/gu, '').replace(/\r\n/g, '\n');
|
||||
let parsed = parseTja(content);
|
||||
|
||||
if (!parsed) {
|
||||
content = iconv.decode(buffer, 'utf-8', { stripBOM: true });
|
||||
content = content.replace(/\uFFFD/g, '').replace(/[\u{0080}-\u{FFFF}]/gu, '').replace(/\r\n/g, '\n');
|
||||
parsed = parseTja(content);
|
||||
}
|
||||
|
||||
if (!parsed) continue;
|
||||
|
||||
for (const diff of ['oni', 'edit'] as const) {
|
||||
if (!parsed[diff]) continue;
|
||||
const factors = factorize(parsed[diff]!);
|
||||
results.push({ songno, diff: diff === 'oni' ? 'oni' : 'ura', ...factors });
|
||||
}
|
||||
}
|
||||
writeFileSync(join(workingDir, 'features.json'), JSON.stringify(results, null, 2));
|
||||
console.log(`Features extracted to ${workingDir}/features.json`);
|
||||
7
script/factor.json
Normal file
7
script/factor.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"physical_density": 1,
|
||||
"stamina_requirement": 1,
|
||||
"pattern_complexity": 1,
|
||||
"rhythmic_complexity": 1,
|
||||
"reading_gimmick": 1
|
||||
}
|
||||
86
script/factorize.ts
Normal file
86
script/factorize.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Bar, Course } from 'tja-parser';
|
||||
|
||||
export interface Factors {
|
||||
physical_density: number;
|
||||
stamina_requirement: number;
|
||||
pattern_complexity: number;
|
||||
rhythmic_complexity: number;
|
||||
reading_gimmick: number;
|
||||
}
|
||||
|
||||
export namespace Factor {
|
||||
export function getAllNotes(course: Course): any[] {
|
||||
const notes: any[] = [];
|
||||
for (const group of course.noteGroups) {
|
||||
if (group instanceof Bar) {
|
||||
notes.push(...group.getNotes());
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function getPhysicalDensity(course: Course): number {
|
||||
const notes = getAllNotes(course);
|
||||
if (notes.length === 0) return 0;
|
||||
const bars = course.noteGroups.length;
|
||||
return notes.length / (bars || 1);
|
||||
}
|
||||
|
||||
export function getStaminaRequirement(course: Course): number {
|
||||
const notes = getAllNotes(course);
|
||||
let maxStream = 0;
|
||||
let currentStream = 0;
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.type !== '0') {
|
||||
currentStream++;
|
||||
} else {
|
||||
maxStream = Math.max(maxStream, currentStream);
|
||||
currentStream = 0;
|
||||
}
|
||||
}
|
||||
return Math.max(maxStream, currentStream) / 100;
|
||||
}
|
||||
|
||||
export function getPatternComplexity(course: Course): number {
|
||||
const notes = getAllNotes(course).filter(n => n.type === '1' || n.type === '2');
|
||||
let transitions = 0;
|
||||
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
if (notes[i].type !== notes[i-1].type) {
|
||||
transitions++;
|
||||
}
|
||||
}
|
||||
return notes.length > 0 ? transitions / notes.length : 0;
|
||||
}
|
||||
|
||||
export function getRhythmicComplexity(course: Course): number {
|
||||
let complexNotes = 0;
|
||||
const allNotes = getAllNotes(course);
|
||||
for (const group of course.noteGroups) {
|
||||
if (group instanceof Bar) {
|
||||
const division = group.getNotes().length;
|
||||
if (division % 4 !== 0 || division > 16) {
|
||||
complexNotes += division;
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNotes.length > 0 ? complexNotes / allNotes.length : 0;
|
||||
}
|
||||
|
||||
export function getReadingGimmick(course: Course): number {
|
||||
// BPM 변화나 Scroll 변화 빈도 측정
|
||||
// tja-parser의 구조에 따라 구현 (여기서는 placeholder)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function factorize(course: Course): Factors {
|
||||
return {
|
||||
physical_density: Factor.getPhysicalDensity(course),
|
||||
stamina_requirement: Factor.getStaminaRequirement(course),
|
||||
pattern_complexity: Factor.getPatternComplexity(course),
|
||||
rhythmic_complexity: Factor.getRhythmicComplexity(course),
|
||||
reading_gimmick: Factor.getReadingGimmick(course)
|
||||
};
|
||||
}
|
||||
51
script/parse.ts
Normal file
51
script/parse.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import tjaParser, { Bar, Branch, Course } from 'tja-parser'
|
||||
|
||||
export function parseTja(tja: string): Partial<Record<'oni' | 'edit', Course>> | null {
|
||||
try {
|
||||
const song = tjaParser.Song.parse(tja);
|
||||
|
||||
let oni: Course | undefined = undefined;
|
||||
let edit: Course | undefined = undefined;
|
||||
|
||||
if (song.course?.oni) {
|
||||
const noteGroups = song.course.oni.noteGroups;
|
||||
oni = song.course.oni;
|
||||
oni.noteGroups = []
|
||||
|
||||
for (const noteGroup of noteGroups) {
|
||||
if (noteGroup instanceof Bar) {
|
||||
oni.pushNoteGroups(noteGroup)
|
||||
}
|
||||
else if (noteGroup instanceof Branch) {
|
||||
const bar = noteGroup.master || noteGroup.advanced || noteGroup.normal;
|
||||
if (bar) {
|
||||
oni.pushNoteGroups(...bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (song.course?.edit) {
|
||||
const noteGroups = song.course.edit.noteGroups;
|
||||
edit = song.course.edit;
|
||||
edit.noteGroups = []
|
||||
|
||||
for (const noteGroup of noteGroups) {
|
||||
if (noteGroup instanceof Bar) {
|
||||
edit.pushNoteGroups(noteGroup)
|
||||
}
|
||||
else if (noteGroup instanceof Branch) {
|
||||
const bar = noteGroup.master || noteGroup.advanced || noteGroup.normal;
|
||||
if (bar) {
|
||||
edit.pushNoteGroups(...bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { oni, edit }
|
||||
}
|
||||
catch (err){
|
||||
console.error(err)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
38
script/predict.ts
Normal file
38
script/predict.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { parseTja } from './parse';
|
||||
import { factorize, Factors } from './factorize';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
async function predict() {
|
||||
const args = process.argv.slice(2);
|
||||
const workingDir = args[0];
|
||||
const scriptDir = args[1];
|
||||
const tjaPath = args[2];
|
||||
const difficulty = (args[3] || 'oni').toLowerCase() as 'oni' | 'edit';
|
||||
|
||||
if (!tjaPath) {
|
||||
console.error('Usage: bun script/predict.ts <working_dir> <script_dir> <tja_path> <difficulty>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const factorJsonPath = join(scriptDir, 'factor.json');
|
||||
const weights: Factors = JSON.parse(readFileSync(factorJsonPath, 'utf-8'));
|
||||
|
||||
const tjaContent = readFileSync(tjaPath, 'utf-8');
|
||||
const parsed = parseTja(tjaContent);
|
||||
|
||||
if (!parsed || !parsed[difficulty]) {
|
||||
console.error(`Error: Could not find difficulty '${difficulty}' in TJA.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const factors = factorize(parsed[difficulty]!);
|
||||
const prediction = Object.keys(factors).reduce((sum, key) =>
|
||||
sum + (factors[key as keyof Factors] * weights[key as keyof Factors]), 0);
|
||||
|
||||
console.log(`Prediction for ${tjaPath} (${difficulty}):`);
|
||||
console.log(`- Predicted Value: ${prediction.toFixed(4)}`);
|
||||
console.log('- Factors:', factors);
|
||||
}
|
||||
|
||||
predict();
|
||||
67
script/train.py
Normal file
67
script/train.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import pandas as pd
|
||||
import json, os, sys, joblib
|
||||
import numpy as np
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
def train(script_dir, working_dir, data_dir, train_count, val_count, margin):
|
||||
features = pd.read_json(os.path.join(working_dir, 'features.json'))
|
||||
measures = pd.read_csv(os.path.join(data_dir, 'measure.csv'))
|
||||
|
||||
features['songno'] = features['songno'].astype(int)
|
||||
measures['songno'] = measures['songno'].astype(int)
|
||||
measures['diff'] = measures['diff'].replace('ura', 'edit')
|
||||
|
||||
df = pd.merge(features, measures, on=['songno', 'diff'])
|
||||
|
||||
with open(os.path.join(script_dir, 'factor.json'), 'r') as f:
|
||||
weights = json.load(f)
|
||||
for col in ['physical_density', 'stamina_requirement', 'pattern_complexity', 'rhythmic_complexity', 'reading_gimmick']:
|
||||
df[col] = df[col] * weights.get(col, 1.0)
|
||||
|
||||
X_cols = ['physical_density', 'stamina_requirement', 'pattern_complexity', 'rhythmic_complexity', 'reading_gimmick']
|
||||
model_path = os.path.join(working_dir, 'model.pkl')
|
||||
model = joblib.load(model_path) if os.path.exists(model_path) else GradientBoostingRegressor(n_estimators=200, learning_rate=0.05, max_depth=3)
|
||||
|
||||
iteration = 1
|
||||
while True:
|
||||
# 3. 데이터 샘플링
|
||||
df_sample = df.sample(n=int(train_count) + int(val_count))
|
||||
train_df, val_df = train_test_split(df_sample, test_size=int(val_count))
|
||||
|
||||
X_train, y_train = train_df[X_cols], train_df['상수']
|
||||
X_val, y_val = val_df[X_cols], val_df['상수']
|
||||
|
||||
# 4. 학습 (최대 10회)
|
||||
for attempt in range(1, 11):
|
||||
model.fit(X_train, y_train)
|
||||
train_err = np.max(np.abs(np.clip(model.predict(X_train), 1.0, 12.0) - y_train))
|
||||
print(f"Iteration {iteration} - Attempt {attempt} - Train Error: {train_err:.4f}")
|
||||
if train_err <= float(margin): break
|
||||
model.set_params(n_estimators=model.n_estimators + 50)
|
||||
|
||||
# 5. 검증
|
||||
pred_val = np.clip(model.predict(X_val), 1.0, 12.0)
|
||||
val_errors = np.abs(pred_val - y_val)
|
||||
|
||||
# 6. 검증 실패 시 재시도
|
||||
if np.any(val_errors > float(margin)):
|
||||
print(f"Validation failed (max error: {np.max(val_errors):.4f}). Retrying...")
|
||||
iteration += 1
|
||||
continue
|
||||
|
||||
val_result = pd.DataFrame({
|
||||
'songno': val_df['songno'],
|
||||
'difficulty': val_df['diff'],
|
||||
'measure': y_val,
|
||||
'predicted_measure': pred_val,
|
||||
'error': val_errors
|
||||
})
|
||||
val_result.to_csv(os.path.join(working_dir, f'validate_result_{iteration}.csv'), index=False)
|
||||
val_result.to_csv(os.path.join(working_dir, 'validate_result.csv'), index=False)
|
||||
break
|
||||
|
||||
joblib.dump(model, model_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
train(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6])
|
||||
108
script/train.ts
Normal file
108
script/train.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { parseTja } from './parse';
|
||||
import { factorize, Factors } from './factorize';
|
||||
import { join } from 'path';
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
|
||||
async function train() {
|
||||
const args = process.argv.slice(2);
|
||||
const workingDir = args[0];
|
||||
const scriptDir = args[1];
|
||||
const dataDir = args[2];
|
||||
const trainCount = parseInt(args[3]) || 100;
|
||||
const validateCount = parseInt(args[4]) || 20;
|
||||
const margin = parseFloat(args[5]) || 0.1;
|
||||
|
||||
const factorJsonPath = join(scriptDir, 'factor.json');
|
||||
// 항상 script/ 위치 참조
|
||||
let weights: Factors = JSON.parse(readFileSync(factorJsonPath, 'utf-8'));
|
||||
|
||||
// 결과물(로그 등)을 저장할 경로 (필요 시 활용)
|
||||
const logPath = join(workingDir, 'training_log.txt');
|
||||
|
||||
const measureCsv = readFileSync(join(dataDir, 'measure.csv'), 'utf-8');
|
||||
const records = parse(measureCsv, { columns: true, skip_empty_lines: true });
|
||||
|
||||
const tjaFiles = readdirSync(join(dataDir, 'tja')).filter(f => f.endsWith('.tja'));
|
||||
const shuffled = tjaFiles.sort(() => 0.5 - Math.random());
|
||||
const trainFiles = shuffled.slice(0, trainCount);
|
||||
const validateFiles = shuffled.slice(trainCount, trainCount + validateCount);
|
||||
|
||||
console.log(`Training with ${trainFiles.length} files...`);
|
||||
|
||||
// 학습 로직 (단순화된 경사 하강법 또는 반복 최적화)
|
||||
let error = Infinity;
|
||||
let iterations = 0;
|
||||
while (error > margin && iterations < 100) {
|
||||
let totalError = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const file of trainFiles) {
|
||||
const songno = file.replace(/\D/g, '');
|
||||
const tjaContent = readFileSync(join(dataDir, 'tja', file), 'utf-8');
|
||||
const parsed = parseTja(tjaContent);
|
||||
if (!parsed) continue;
|
||||
|
||||
for (const diff of ['oni', 'edit'] as const) {
|
||||
const course = parsed[diff];
|
||||
if (!course) continue;
|
||||
|
||||
const target = records.find((r: any) => r.songno === songno && (r.diff === (diff === 'oni' ? 'oni' : 'ura')));
|
||||
if (!target) {
|
||||
// console.log(`[!] No target for ${songno} diff=${diff}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const factors = factorize(course);
|
||||
const prediction = Object.keys(factors).reduce((sum, key) =>
|
||||
sum + (factors[key as keyof Factors] * weights[key as keyof Factors]), 0);
|
||||
|
||||
const targetValue = parseFloat(target.상수);
|
||||
const diff_val = targetValue - prediction;
|
||||
totalError += Math.abs(diff_val);
|
||||
count++;
|
||||
|
||||
// 가중치 업데이트
|
||||
for (const key in weights) {
|
||||
const k = key as keyof Factors;
|
||||
weights[k] += diff_val * factors[k] * 0.05; // 학습률 조정
|
||||
}
|
||||
}
|
||||
}
|
||||
error = totalError / (count || 1);
|
||||
console.log(`Iteration ${iterations}: Mean Error = ${error.toFixed(4)}`);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
writeFileSync(factorJsonPath, JSON.stringify(weights, null, 2));
|
||||
writeFileSync(join(workingDir, 'training_result.json'), JSON.stringify({ finalError: error, weights }, null, 2));
|
||||
console.log(`Training complete. Weights saved to ${factorJsonPath}, result saved to ${workingDir}`);
|
||||
|
||||
// 검증 로직
|
||||
console.log('\nValidation Results:');
|
||||
for (const file of validateFiles) {
|
||||
const songno = file.replace(/\D/g, '');
|
||||
const tjaContent = readFileSync(join(dataDir, 'tja', file), 'utf-8');
|
||||
const parsed = parseTja(tjaContent);
|
||||
if (!parsed) continue;
|
||||
|
||||
for (const diff of ['oni', 'edit'] as const) {
|
||||
const course = parsed[diff];
|
||||
if (!course) continue;
|
||||
|
||||
const target = records.find((r: any) => r.songno === songno && (r.diff === (diff === 'oni' ? 'oni' : 'ura')));
|
||||
if (!target) {
|
||||
console.log(`[!] No match for ${songno} diff=${diff === 'oni' ? 'oni' : 'ura'}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const factors = factorize(course);
|
||||
const prediction = Object.keys(factors).reduce((sum, key) =>
|
||||
sum + (factors[key as keyof Factors] * weights[key as keyof Factors]), 0);
|
||||
|
||||
console.log(`[${songno}] Target: ${target.상수}, Predicted: ${prediction.toFixed(2)}, Diff: ${Math.abs(parseFloat(target.상수) - prediction).toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
train();
|
||||
Reference in New Issue
Block a user