train
This commit is contained in:
163
src/factorize.ts
Normal file
163
src/factorize.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Song, Note, Bar, HitNote } from 'tja-parser';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as iconv from 'iconv-lite';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
|
||||
interface Features {
|
||||
global_nps: number;
|
||||
effective_nps: number;
|
||||
peak_nps_1s: number;
|
||||
peak_nps_2s: number;
|
||||
peak_nps_5s: number;
|
||||
nps_variance: number;
|
||||
nps_spike_index: number;
|
||||
// 구간별 NPS (곡을 4등분)
|
||||
nps_q1: number; nps_q2: number; nps_q3: number; nps_q4: number;
|
||||
max_nps_delta: number; // 인접 구간 간 최대 NPS 변화량
|
||||
longest_stream: number;
|
||||
avg_stream_length: number;
|
||||
stream_count: number;
|
||||
stream_ratio: number;
|
||||
resting_ratio: number;
|
||||
// 패턴 및 기술
|
||||
don_ratio: number;
|
||||
ka_ratio: number;
|
||||
color_transition_ratio: number;
|
||||
pattern_complexity: number;
|
||||
hand_switch_ratio: number;
|
||||
off_beat_ratio: number;
|
||||
rhythm_std_dev: number;
|
||||
rhythmic_entropy: number;
|
||||
// 기믹 및 변속
|
||||
gimmick_density: number;
|
||||
sv_variance: number;
|
||||
bpm_variance: number;
|
||||
max_bpm: number;
|
||||
min_bpm: number;
|
||||
max_sv: number;
|
||||
total_notes: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function getFeatures(song: Song, diff: 'oni' | 'edit'): Features | null {
|
||||
const course = song.course[diff];
|
||||
if (!course) return null;
|
||||
|
||||
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
|
||||
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
|
||||
if (hitNotes.length === 0) return null;
|
||||
|
||||
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
|
||||
const startT = Math.min(...timings);
|
||||
const endT = Math.max(...timings);
|
||||
const duration = endT - startT;
|
||||
if (duration <= 0) return null;
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
|
||||
|
||||
const getPeakNPS = (window: number) => {
|
||||
let max = 0;
|
||||
for (let t = startT; t < endT; t += 0.2) {
|
||||
const count = timings.filter(time => time >= t && time < t + window).length;
|
||||
if (count / window > max) max = count / window;
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
// 구간별 NPS 계산
|
||||
const getNPSInRange = (t1: number, t2: number) => {
|
||||
const count = timings.filter(t => t >= t1 && t < t2).length;
|
||||
return count / ((t2 - t1) || 1);
|
||||
};
|
||||
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
|
||||
const nps_qs = [
|
||||
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
|
||||
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
|
||||
];
|
||||
|
||||
let max_delta = 0;
|
||||
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
|
||||
|
||||
const streams: number[] = [];
|
||||
let currentS = 0, restTime = 0;
|
||||
for (const interval of intervals) {
|
||||
if (interval <= 0.185) currentS++;
|
||||
else {
|
||||
if (currentS > 0) streams.push(currentS + 1);
|
||||
currentS = 0;
|
||||
if (interval >= 1.0) restTime += interval;
|
||||
}
|
||||
}
|
||||
if (currentS > 0) streams.push(currentS + 1);
|
||||
|
||||
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
|
||||
const kaCount = hitNotes.length - donCount;
|
||||
|
||||
const svs = bars.map(b => b.getScroll());
|
||||
const bpms = bars.map(b => b.getBpm());
|
||||
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
|
||||
|
||||
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
|
||||
|
||||
return {
|
||||
global_nps: hitNotes.length / duration,
|
||||
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
|
||||
peak_nps_1s: getPeakNPS(1),
|
||||
peak_nps_2s: getPeakNPS(2),
|
||||
peak_nps_5s: getPeakNPS(5),
|
||||
nps_variance: std(intervals),
|
||||
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
|
||||
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
|
||||
max_nps_delta: max_delta,
|
||||
longest_stream: Math.max(0, ...streams),
|
||||
avg_stream_length: avg(streams),
|
||||
stream_count: streams.length,
|
||||
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
|
||||
resting_ratio: restTime / duration,
|
||||
don_ratio: donCount / hitNotes.length,
|
||||
ka_ratio: kaCount / hitNotes.length,
|
||||
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
|
||||
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
|
||||
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
|
||||
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
|
||||
rhythm_std_dev: std(intervals),
|
||||
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
|
||||
gimmick_density: gimmickCount / duration,
|
||||
sv_variance: std(svs),
|
||||
bpm_variance: std(bpms),
|
||||
max_bpm: Math.max(...bpms),
|
||||
min_bpm: Math.min(...bpms),
|
||||
max_sv: Math.max(...svs),
|
||||
total_notes: hitNotes.length,
|
||||
level: course.getLevel()
|
||||
};
|
||||
}
|
||||
|
||||
const tjaDir = process.argv[2] || 'datas/tja';
|
||||
const measureCsv = process.argv[3] || 'datas/measure.csv';
|
||||
const outputFile = process.argv[4] || 'datas/dataset.csv';
|
||||
|
||||
const records = parse(fs.readFileSync(measureCsv, 'utf-8'), { columns: true });
|
||||
const dataset: any[] = [];
|
||||
console.log(`Extracting ${records.length} records...`);
|
||||
|
||||
for (const record of records) {
|
||||
const { songno, diff, 상수 } = record;
|
||||
const tjaPath = path.join(tjaDir, `${songno}.tja`);
|
||||
if (!fs.existsSync(tjaPath)) continue;
|
||||
try {
|
||||
const song = Song.parse(iconv.decode(fs.readFileSync(tjaPath), 'shift-jis'));
|
||||
const f = getFeatures(song, diff === 'ura' ? 'edit' : 'oni');
|
||||
if (f) dataset.push({ ...f, target: parseFloat(상수) });
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (dataset.length > 0) {
|
||||
const header = Object.keys(dataset[0]).join(',');
|
||||
const rows = dataset.map(d => Object.values(d).join(',')).join('\n');
|
||||
fs.writeFileSync(outputFile, `${header}\n${rows}`);
|
||||
console.log(`Saved ${dataset.length} samples.`);
|
||||
}
|
||||
135
src/predict.ts
Normal file
135
src/predict.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Song, Note, Bar, HitNote } from 'tja-parser';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as iconv from 'iconv-lite';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
function getFeatures(song: Song, diff: 'oni' | 'edit') {
|
||||
const course = song.course[diff];
|
||||
if (!course) return null;
|
||||
|
||||
const bars = course.noteGroups.filter(ng => typeof (ng as any).getNotes === 'function') as Bar[];
|
||||
const hitNotes = bars.flatMap(b => b.getNotes()).filter(n => [1, 2, 3, 4].includes(n.toJSON().type));
|
||||
if (hitNotes.length === 0) return null;
|
||||
|
||||
const timings = hitNotes.map(n => n.getTimingMS() / 1000);
|
||||
const startT = Math.min(...timings);
|
||||
const endT = Math.max(...timings);
|
||||
const duration = endT - startT;
|
||||
if (duration <= 0) return null;
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 1; i < timings.length; i++) intervals.push(timings[i] - timings[i-1]);
|
||||
|
||||
const getPeakNPS = (window: number) => {
|
||||
let max = 0;
|
||||
for (let t = startT; t < endT; t += 0.2) {
|
||||
const count = timings.filter(time => time >= t && time < t + window).length;
|
||||
if (count / window > max) max = count / window;
|
||||
}
|
||||
return max;
|
||||
};
|
||||
|
||||
const getNPSInRange = (t1: number, t2: number) => {
|
||||
const count = timings.filter(t => t >= t1 && t < t2).length;
|
||||
return count / ((t2 - t1) || 1);
|
||||
};
|
||||
const q = [0, 0.25, 0.5, 0.75, 1.0].map(p => startT + duration * p);
|
||||
const nps_qs = [
|
||||
getNPSInRange(q[0], q[1]), getNPSInRange(q[1], q[2]),
|
||||
getNPSInRange(q[2], q[3]), getNPSInRange(q[3], q[4])
|
||||
];
|
||||
|
||||
let max_delta = 0;
|
||||
for (let i = 1; i < nps_qs.length; i++) max_delta = Math.max(max_delta, Math.abs(nps_qs[i] - nps_qs[i-1]));
|
||||
|
||||
const streams: number[] = [];
|
||||
let currentS = 0, restTime = 0;
|
||||
for (const interval of intervals) {
|
||||
if (interval <= 0.185) currentS++;
|
||||
else {
|
||||
if (currentS > 0) streams.push(currentS + 1);
|
||||
currentS = 0;
|
||||
if (interval >= 1.0) restTime += interval;
|
||||
}
|
||||
}
|
||||
if (currentS > 0) streams.push(currentS + 1);
|
||||
|
||||
const donCount = hitNotes.filter(n => [1, 3].includes(n.toJSON().type)).length;
|
||||
const kaCount = hitNotes.length - donCount;
|
||||
|
||||
const svs = bars.map(b => b.getScroll());
|
||||
const bpms = bars.map(b => b.getBpm());
|
||||
const gimmickCount = svs.filter((v, i) => i > 0 && v !== svs[i-1]).length + bpms.filter((v, i) => i > 0 && v !== bpms[i-1]).length;
|
||||
|
||||
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
const std = (arr: number[]) => Math.sqrt(avg(arr.map(v => Math.pow(v - avg(arr), 2))));
|
||||
|
||||
return {
|
||||
global_nps: hitNotes.length / duration,
|
||||
effective_nps: hitNotes.length / (duration - (intervals.filter(v => v >= 1.0).reduce((a, b) => a + b, 0)) || 1),
|
||||
peak_nps_1s: getPeakNPS(1),
|
||||
peak_nps_2s: getPeakNPS(2),
|
||||
peak_nps_5s: getPeakNPS(5),
|
||||
nps_variance: std(intervals),
|
||||
nps_spike_index: getPeakNPS(1) / (hitNotes.length / duration || 1),
|
||||
nps_q1: nps_qs[0], nps_q2: nps_qs[1], nps_q3: nps_qs[2], nps_q4: nps_qs[3],
|
||||
max_nps_delta: max_delta,
|
||||
longest_stream: Math.max(0, ...streams),
|
||||
avg_stream_length: avg(streams),
|
||||
stream_count: streams.length,
|
||||
stream_ratio: streams.reduce((a, b) => a + b, 0) / hitNotes.length,
|
||||
resting_ratio: restTime / duration,
|
||||
don_ratio: donCount / hitNotes.length,
|
||||
ka_ratio: kaCount / hitNotes.length,
|
||||
color_transition_ratio: hitNotes.filter((n, i) => i > 0 && [1, 3].includes(n.toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type)).length / hitNotes.length,
|
||||
pattern_complexity: hitNotes.filter((n, i) => i > 1 && ([1, 3].includes(hitNotes[i-2].toJSON().type) !== [1, 3].includes(hitNotes[i-1].toJSON().type) || [1, 3].includes(hitNotes[i-1].toJSON().type) !== [1, 3].includes(n.toJSON().type))).length / hitNotes.length,
|
||||
hand_switch_ratio: streams.filter(s => s % 2 !== 0).length / (streams.length || 1),
|
||||
off_beat_ratio: hitNotes.filter(n => Number((n.getTiming() as any).d) % 3 === 0).length / hitNotes.length,
|
||||
rhythm_std_dev: std(intervals),
|
||||
rhythmic_entropy: new Set(hitNotes.map(n => Number((n.getTiming() as any).d))).size / 10,
|
||||
gimmick_density: gimmickCount / duration,
|
||||
sv_variance: std(svs),
|
||||
bpm_variance: std(bpms),
|
||||
max_bpm: Math.max(...bpms),
|
||||
min_bpm: Math.min(...bpms),
|
||||
max_sv: Math.max(...svs),
|
||||
total_notes: hitNotes.length,
|
||||
level: course.getLevel()
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const modelPath = process.argv[2] || 'model/constant_predictor.joblib';
|
||||
const tjaPath = process.argv[3];
|
||||
const diff: any = process.argv[4] || 'oni';
|
||||
|
||||
if (!tjaPath) {
|
||||
console.log("Usage: bun run src/predict.ts <model_path> <tja_path> [oni|edit]");
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(tjaPath);
|
||||
const content = iconv.decode(buffer, 'shift-jis');
|
||||
const song = Song.parse(content);
|
||||
const features = getFeatures(song, diff);
|
||||
|
||||
if (!features) {
|
||||
console.error("Course not found or no notes.");
|
||||
return;
|
||||
}
|
||||
|
||||
const python = spawn('python3', ['model/predict.py', modelPath]);
|
||||
python.stdin.write(JSON.stringify(features));
|
||||
python.stdin.end();
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
console.log(`Predicted Constant: ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
console.error(`Python Error: ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
26
src/validate_all.py
Normal file
26
src/validate_all.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import pandas as pd
|
||||
from sklearn.metrics import mean_absolute_error
|
||||
import joblib
|
||||
import sys
|
||||
|
||||
def validate(dataset_path, model_path):
|
||||
df = pd.read_csv(dataset_path)
|
||||
X = df.drop('target', axis=1)
|
||||
y = df['target']
|
||||
|
||||
model = joblib.load(model_path)
|
||||
preds = model.predict(X)
|
||||
mae = mean_absolute_error(y, preds)
|
||||
|
||||
print(f"Overall MAE on full dataset: {mae:.4f}")
|
||||
|
||||
# Check error distribution
|
||||
diffs = (y - preds).abs()
|
||||
within_01 = (diffs <= 0.1).mean() * 100
|
||||
within_03 = (diffs <= 0.3).mean() * 100
|
||||
|
||||
print(f"Samples within 0.1 error: {within_01:.2f}%")
|
||||
print(f"Samples within 0.3 error: {within_03:.2f}%")
|
||||
|
||||
if __name__ == "__main__":
|
||||
validate(sys.argv[1], sys.argv[2])
|
||||
Reference in New Issue
Block a user