This commit is contained in:
2026-04-24 15:42:54 +09:00
parent be4c383b6f
commit cd38fd49e2
18 changed files with 11609 additions and 235 deletions

View File

@@ -1,16 +1,23 @@
import { parseTja } from './parse';
import { factorize } from './factorize';
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } 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.');
const featurePath = join(workingDir, 'factors.json');
if (existsSync(featurePath)) {
console.log('factors.json already exists. Skipping extraction.');
process.exit(0);
}
// 작업 디렉토리 존재 확인 및 생성
if (!existsSync(workingDir)) {
mkdirSync(workingDir, { recursive: true });
}
const tjaDir = join(dataDir, 'tja');
const results: any[] = [];
@@ -38,5 +45,5 @@ for (const file of readdirSync(tjaDir)) {
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`);
writeFileSync(featurePath, JSON.stringify(results, null, 2));
console.log(`Features extracted to ${featurePath}`);

View File

@@ -1,7 +0,0 @@
{
"physical_density": 1,
"stamina_requirement": 1,
"pattern_complexity": 1,
"rhythmic_complexity": 1,
"reading_gimmick": 1
}

View File

@@ -1,86 +1,55 @@
import { Bar, Course } from 'tja-parser';
export interface Factors {
physical_density: number;
stamina_requirement: number;
pattern_complexity: number;
rhythmic_complexity: number;
reading_gimmick: number;
}
import { Course, Bar } from 'tja-parser';
export namespace Factor {
export function getAllNotes(course: Course): any[] {
const notes: any[] = [];
// 노트 추출 및 정렬 (시간순)
export function getAllNotes(course: Course) {
const notes: { type: number, time: number }[] = [];
for (const group of course.noteGroups) {
if (group instanceof Bar) {
notes.push(...group.getNotes());
// 파서 구조에 따라 노트 추출 방식 조정 필요
notes.push(...group.getNotes().map((note) => {
return {
//@ts-expect-error
type: note.type,
time: note.getTimingMS()
}
}));
}
}
return notes;
return notes.sort((a, b) => a.time - b.time).filter(n => [1, 2, 3, 4].includes(n.type));
}
export function getPhysicalDensity(course: Course): number {
const notes = getAllNotes(course);
export function getAverageDensity(notes: { time: number }[]): number {
if (notes.length < 2) return 0;
const duration = (notes[notes.length - 1].time - notes[0].time) / 1000;
return duration > 0 ? notes.length / duration : 0;
}
export function getPeakDensity(notes: { time: number }[]): number {
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;
let maxPeak = 0;
const windowSize = 1000;
for (let i = 0; i < notes.length; i++) {
let count = 0;
const startTime = notes[i].time;
for (let j = i; j < notes.length && notes[j].time < startTime + windowSize; j++) {
count++;
}
maxPeak = Math.max(maxPeak, count);
}
return Math.max(maxStream, currentStream) / 100;
return maxPeak;
}
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 getMaxCombo(notes: any[]): number {
return notes.length;
}
}
export function factorize(course: Course): Factors {
export function factorize(course: Course) {
const notes = Factor.getAllNotes(course);
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)
average_density: Factor.getAverageDensity(notes),
peak_density: Factor.getPeakDensity(notes),
max_combo: Factor.getMaxCombo(notes)
};
}
}

View File

@@ -1,38 +1,97 @@
import { parseTja } from './parse';
import { factorize, Factors } from './factorize';
import { factorize } from './factorize';
import { join } from 'path';
import { readFileSync } from 'fs';
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
import { execSync } from 'child_process';
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';
/**
* 예측 엔진: 모델 실행을 위해 Python 환경을 호출
*/
function runInference(workingDir: string, factors: Record<string, number>): number {
const tempFactorPath = join(workingDir, `temp_feat_${Date.now()}.json`);
writeFileSync(tempFactorPath, JSON.stringify(factors));
if (!tjaPath) {
console.error('Usage: bun script/predict.ts <working_dir> <script_dir> <tja_path> <difficulty>');
const pythonScript = `
import json, torch, sys
import numpy as np
import torch.nn as nn
# 모델 정의 (학습된 구조와 동일해야 함)
class DifficultyNet(nn.Module):
def __init__(self, input_dim):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, 128),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Dropout(0.4),
nn.Linear(128, 64),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, 32),
nn.ReLU(),
nn.Linear(32, 1),
nn.Sigmoid()
)
def forward(self, x): return self.net(x)
def predict():
try:
with open('${tempFactorPath}', 'r') as f: factors = json.load(f)
with open('${join(workingDir, 'scaler.json')}', 'r') as f: s = json.load(f)
X = np.array([[factors[col] for col in s['cols']]], dtype=np.float32)
# 정규화 (Robust/Standard 지원)
if 'center' in s: X_scaled = (X - np.array(s['center'])) / np.array(s['scale'])
else: X_scaled = (X - np.array(s.get('mean', 0))) / np.array(s.get('std', s.get('scale', 1)))
model = DifficultyNet(len(s['cols']))
model.load_state_dict(torch.load('${join(workingDir, 'model.pth')}', map_location='cpu'))
model.eval()
with torch.no_grad():
input_tensor = torch.from_numpy(X_scaled).float()
pred = model(input_tensor) * 11 + 1
print(float(pred.item()))
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__": predict()
`;
const tempPyPath = join(workingDir, `inference_${Date.now()}.py`);
writeFileSync(tempPyPath, pythonScript);
try {
const result = execSync(`python3 ${tempPyPath}`).toString().trim();
return parseFloat(result);
} finally {
if (existsSync(tempFactorPath)) unlinkSync(tempFactorPath);
if (existsSync(tempPyPath)) unlinkSync(tempPyPath);
}
}
function main() {
const [workingDir, tjaPath, difficulty = 'oni'] = process.argv.slice(2);
if (!workingDir || !tjaPath) {
console.log("Usage: bun run script/predict.ts <workingDir> <tjaPath> [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);
const chart = parsed[difficulty.toLowerCase() as 'oni' | 'edit'];
if (!parsed || !parsed[difficulty]) {
console.error(`Error: Could not find difficulty '${difficulty}' in TJA.`);
if (!chart) {
console.error(`Error: Difficulty '${difficulty}' not found.`);
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);
const score = runInference(workingDir, factorize(chart));
console.log(`\n🎯 Predicted Difficulty: ${score.toFixed(2)}`);
}
predict();
main();

View File

@@ -1,67 +1,152 @@
import pandas as pd
import json, os, sys, joblib
import json, os, sys
import numpy as np
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.model_selection import train_test_split
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.preprocessing import StandardScaler
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)
MAX_MARGIN_LIMIT = 3
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)
class DifficultyNet(nn.Module):
def __init__(self, input_dim):
super(DifficultyNet, self).__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, 128),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Dropout(0.4),
nn.Linear(128, 64),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, 32),
nn.ReLU(),
nn.Linear(32, 1),
nn.Sigmoid()
)
def forward(self, x):
return self.net(x)
def train(script_dir, working_dir, data_dir, train_count, val_count, margin, val_iterations):
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
print(f"🚀 Using Device: {device}")
# 데이터 로드 및 병합
factors_path = os.path.join(working_dir, 'factors.json')
if not os.path.exists(factors_path):
os.system(f"bun run {os.path.join(script_dir, 'factorize.ts')} {data_dir} {working_dir}")
df_feat = pd.read_json(factors_path)
df_meas = pd.read_csv(os.path.join(data_dir, 'measure.csv'))
df_meas['diff'] = df_meas['diff'].replace('ura', 'edit')
df_feat['songno'], df_meas['songno'] = df_feat['songno'].astype(int), df_meas['songno'].astype(int)
df = pd.merge(df_feat, df_meas, on=['songno', 'diff'])
exclude_cols = ['songno', 'diff', 'title', 'course', '상수', 'predicted_measure', 'error']
X_cols = [c for c in df_feat.columns if c not in exclude_cols]
scaler = StandardScaler()
df[X_cols] = scaler.fit_transform(df[X_cols])
with open(os.path.join(working_dir, 'scaler.json'), 'w') as f:
json.dump({'mean': scaler.mean_.tolist(), 'std': scaler.scale_.tolist(), 'cols': X_cols}, f)
model_path = os.path.join(working_dir, 'model.pth')
margin = float(margin)
val_iterations = int(val_iterations)
model = DifficultyNet(len(X_cols)).to(device)
attempt = 1
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))
print(f"\n[Attempt {attempt}] " + "-"*40)
if os.path.exists(model_path):
try: model.load_state_dict(torch.load(model_path, map_location=device))
except: pass
X_train, y_train = train_df[X_cols], train_df['상수']
X_val, y_val = val_df[X_cols], val_df['상수']
train_df = df.sample(n=min(int(train_count), len(df)))
X_train = torch.FloatTensor(train_df[X_cols].values).to(device)
y_train = torch.FloatTensor(((train_df['상수'].values - 1) / 11)).unsqueeze(1).to(device)
# 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)
base_lr = 0.001
optimizer = optim.Adam(model.parameters(), lr=base_lr, weight_decay=1e-5)
criterion = nn.L1Loss()
# 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
model.train()
epoch = 0
while True:
optimizer.zero_grad()
preds_train = model(X_train)
loss = criterion(preds_train, y_train) # MSELoss 권장
loss.backward()
optimizer.step()
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
# 현재 학습 데이터에 대한 오차 분석
with torch.no_grad():
diff = torch.abs((preds_train * 11 + 1) - (y_train * 11 + 1))
train_mae = torch.mean(diff).item()
train_max = torch.max(diff).item()
if epoch % 1000 == 0:
print(f" - Ep {epoch:5d} | MAE: {train_mae:.4f} | MAX: {train_max:.4f}")
# ✅ 조건 강화: MAE뿐만 아니라 MAX도 어느 정도 잡혔을 때만 검증으로 이동
if train_mae < margin and train_max < (margin * MAX_MARGIN_LIMIT):
print(f" ✅ Train Goal Reached (MAE: {train_mae} < {margin:.4f}, MAX: {train_max:.4f} < {(margin * MAX_MARGIN_LIMIT):.4f}). Moving to Val.")
break
epoch += 1
if epoch > 50000:
print(" ⚠️ Timed out. Resampling...")
break
if train_mae >= margin:
torch.save(model.state_dict(), model_path)
attempt += 1
continue
joblib.dump(model, model_path)
# 3. 검증 단계
print(f"3. Validating {val_iterations} iterations...")
model.eval()
all_passed = True
with torch.no_grad():
for i in range(1, val_iterations + 1):
val_df = df.sample(n=min(int(val_count), len(df)))
X_val = torch.FloatTensor(val_df[X_cols].values).to(device)
y_val_raw = val_df['상수'].values
preds = model(X_val) * 11 + 1
y_val_tensor = torch.FloatTensor(y_val_raw).unsqueeze(1).to(device)
diff_tensor = torch.abs(preds - y_val_tensor)
mae = torch.mean(diff_tensor).item()
max_error = torch.max(diff_tensor).item()
# csv 저장
# preds를 CPU 넘파이로 변환하고 1차원으로 펴주는 과정이 포함되어야 합니다.
preds_np = preds.detach().cpu().numpy().flatten() if torch.is_tensor(preds) else preds
res_df = val_df[['songno', 'diff', '상수']].copy()
res_df['predicted_measure'] = preds_np
res_df['error'] = np.abs(preds_np - y_val_raw)
output_file = os.path.join(working_dir, f'validate_result_{i}.csv')
res_df.to_csv(output_file, index=False)
if mae <= margin and max_error <= (margin * MAX_MARGIN_LIMIT):
print(f" [Iter {i}] ✅ PASS (MAE: {mae:.4f}, MAX: {max_error:.4f})")
else:
print(f" [Iter {i}] ❌ FAIL (MAE: {mae:.4f} > {margin}, MAX: {max_error:.4f} > {margin * MAX_MARGIN_LIMIT})")
all_passed = False
break
if all_passed:
print(f"\n✨ Final Success!")
torch.save(model.state_dict(), model_path)
return
else:
torch.save(model.state_dict(), model_path)
attempt += 1
if __name__ == "__main__":
train(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6])
if len(sys.argv) < 8: sys.exit(1)
train(*sys.argv[1:8])

View File

@@ -30,10 +30,12 @@ async function train() {
console.log(`Training with ${trainFiles.length} files...`);
// 학습 로직 (단순화된 경사 하강법 또는 반복 최적화)
// 학습 로직 (Sigmoid + [1, 12] scaling)
const sigmoid = (x: number) => 1 / (1 + Math.exp(-x));
let error = Infinity;
let iterations = 0;
while (error > margin && iterations < 100) {
while (error > margin && iterations < 10) { // Spec에 따라 10번 반복
let totalError = 0;
let count = 0;
@@ -48,24 +50,32 @@ async function train() {
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;
}
if (!target) 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 normalizedFactors = {
physical_density: Math.min(factors.physical_density / 20, 1),
stamina_requirement: Math.min(factors.stamina_requirement, 1),
pattern_complexity: Math.min(factors.pattern_complexity, 1),
rhythmic_complexity: Math.min(factors.rhythmic_complexity, 1),
reading_gimmick: Math.min(factors.reading_gimmick, 1)
};
const rawPrediction = Object.keys(normalizedFactors).reduce((sum, key) =>
sum + (normalizedFactors[key as keyof Factors] * weights[key as keyof Factors]), 0);
const prediction = (sigmoid(rawPrediction) * 11) + 1; // [1, 12]
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; // 학습률 조정
weights[k] += diff_val * normalizedFactors[k] * 0.01;
}
}
}