| |
| """ |
| train_classifier.py — Train sklearn RandomForest classifier on labelled data. |
| |
| Usage: |
| python3 scripts/train_classifier.py |
| |
| Reads all JSON files from training_data/ directory. |
| Saves model to training_data/models/instrument_classifier.pkl |
| """ |
|
|
| import json |
| import os |
| import sys |
| from pathlib import Path |
|
|
| |
| PROJECT_ROOT = Path(__file__).parent.parent |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| |
| |
| TRAINING_DIR = Path(os.environ.get("MAESTRO_TRAINING_DIR", PROJECT_ROOT / "training_data")) |
| MODEL_DIR = TRAINING_DIR / "models" |
| MODEL_PATH = MODEL_DIR / "instrument_classifier.pkl" |
| LABEL_ENCODER_PATH = MODEL_DIR / "label_encoder.pkl" |
|
|
| |
| FEATURE_KEYS = [ |
| "spectral_centroid_hz", |
| "spectral_rolloff_hz", |
| "spectral_flatness", |
| "harmonic_ratio", |
| "zero_crossing_rate", |
| "percussive_ratio", |
| "dynamic_range_db", |
| "inharmonicity_score", |
| "tempo_bpm", |
| "pitch_std_hz", |
| "attack_sharpness", |
| ] |
|
|
|
|
| def extract_feature_vector(features: dict) -> list: |
| """Extract a flat feature vector from a features dict.""" |
| vec = [] |
| for key in FEATURE_KEYS: |
| vec.append(float(features.get(key, 0.0) or 0.0)) |
|
|
| |
| mel = features.get("mel_band_stats", {}) or {} |
| for sub in ["mel_sub", "mel_bass", "mel_low_mid", "mel_high_mid", "mel_high"]: |
| vec.append(float(mel.get(sub, 0.0) or 0.0)) |
|
|
| |
| bands = features.get("frequency_bands_db", {}) or {} |
| for sub in ["sub_bass_db", "bass_db", "low_mid_db", "high_mid_db", "presence_db", "air_db"]: |
| vec.append(float(bands.get(sub, 0.0) or 0.0)) |
|
|
| |
| mfcc = features.get("mfcc_means", []) or [] |
| for i in range(13): |
| vec.append(float(mfcc[i]) if i < len(mfcc) else 0.0) |
|
|
| |
| sc = features.get("spectral_contrast", []) or [] |
| for i in range(7): |
| vec.append(float(sc[i]) if i < len(sc) else 0.0) |
|
|
| return vec |
|
|
|
|
| def load_training_data(): |
| """Load all JSON training records from training_data/ directory.""" |
| X = [] |
| y = [] |
| skipped = 0 |
|
|
| for json_file in TRAINING_DIR.glob("*.json"): |
| try: |
| with open(json_file) as f: |
| records = json.load(f) |
|
|
| |
| if isinstance(records, dict): |
| records = [records] |
|
|
| for record in records: |
| try: |
| features = record.get("features", {}) |
| analysis = record.get("analysis", {}) |
| instruments = analysis.get("instruments", []) |
|
|
| if not instruments: |
| skipped += 1 |
| continue |
|
|
| label = instruments[0].get("model") |
| if not label: |
| skipped += 1 |
| continue |
|
|
| vec = extract_feature_vector(features) |
| X.append(vec) |
| y.append(label) |
|
|
| except Exception as e: |
| skipped += 1 |
| continue |
|
|
| except Exception as e: |
| print(f"Warning: Could not read {json_file}: {e}") |
| continue |
|
|
| if skipped > 0: |
| print(f"Skipped {skipped} records (missing features or labels)") |
|
|
| return X, y |
|
|
|
|
| def main(): |
| print("Loading training data...") |
| X, y = load_training_data() |
|
|
| n_samples = len(X) |
| print(f"Found {n_samples} labelled samples") |
|
|
| if n_samples < 5: |
| print(f"Insufficient data ({n_samples} samples). Need at least 5 to train.") |
| sys.exit(1) |
|
|
| import numpy as np |
| from sklearn.ensemble import RandomForestClassifier |
| from sklearn.model_selection import cross_val_score |
| from sklearn.pipeline import Pipeline |
| from sklearn.preprocessing import LabelEncoder, StandardScaler |
| import joblib |
|
|
| X_arr = np.array(X) |
| le = LabelEncoder() |
| y_enc = le.fit_transform(y) |
|
|
| n_classes = len(le.classes_) |
| print(f"Labels: {n_classes} unique instruments") |
|
|
| pipeline = Pipeline([ |
| ("scaler", StandardScaler()), |
| ("clf", RandomForestClassifier(n_estimators=100, random_state=42)), |
| ]) |
|
|
| |
| cv = min(3, n_samples // n_classes) if n_samples >= 3 else 2 |
| cv = max(cv, 2) |
|
|
| if n_samples >= cv * n_classes: |
| try: |
| scores = cross_val_score(pipeline, X_arr, y_enc, cv=cv, scoring="accuracy") |
| print(f"Cross-validation accuracy (cv={cv}): {scores.mean():.3f} ± {scores.std():.3f}") |
| except Exception as e: |
| print(f"Cross-validation skipped: {e}") |
| else: |
| print("Not enough samples for cross-validation, skipping.") |
|
|
| |
| print("Training final model on all data...") |
| pipeline.fit(X_arr, y_enc) |
|
|
| |
| MODEL_DIR.mkdir(parents=True, exist_ok=True) |
| joblib.dump(pipeline, MODEL_PATH) |
| joblib.dump(le, LABEL_ENCODER_PATH) |
|
|
| print(f"Model saved to: {MODEL_PATH}") |
| print(f"Label encoder saved to: {LABEL_ENCODER_PATH}") |
| print("Training complete.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|