""" DSP Classifier v2 — Training Pipeline ======================================= Extracts v2 features from raw audio, trains multiple classifiers, selects the best, calibrates probabilities, and saves the model. Run: python src/train_dsp_v2.py """ import os import sys import time import json import numpy as np import pandas as pd import joblib import warnings from pathlib import Path from tqdm import tqdm from sklearn.model_selection import StratifiedShuffleSplit, cross_val_score from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier from sklearn.metrics import ( accuracy_score, classification_report, confusion_matrix, f1_score, roc_auc_score, precision_score, recall_score ) from sklearn.calibration import CalibratedClassifierCV from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline warnings.filterwarnings("ignore") # Add project root to path sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from src.config import DATA_DIR, SAMPLE_RATE from src.features.extract_dsp_v2 import extract_all_features_v2 # Try importing optional dependencies try: import xgboost as xgb HAS_XGB = True except ImportError: HAS_XGB = False try: import lightgbm as lgb HAS_LGB = True except ImportError: HAS_LGB = False try: import optuna HAS_OPTUNA = True except ImportError: HAS_OPTUNA = False MODELS_DIR = os.path.join(os.path.dirname(__file__), '..', 'models') os.makedirs(MODELS_DIR, exist_ok=True) RESULTS_DIR = os.path.join(os.path.dirname(__file__), '..', 'results') os.makedirs(RESULTS_DIR, exist_ok=True) # ============================================================ # Step 1: Scan raw data and extract v2 features # ============================================================ def scan_raw_data(): """Scan raw data directories for audio files with proper labels.""" raw_human = os.path.join(DATA_DIR, 'raw', 'human') raw_ai = os.path.join(DATA_DIR, 'raw', 'ai') records = [] # Scan human files if os.path.exists(raw_human): for root, dirs, files in os.walk(raw_human): for f in files: if f.endswith(('.flac', '.wav', '.mp3', '.ogg')): full_path = os.path.join(root, f) # Extract language from filename or path lang = 'unknown' for code in ['en', 'ta', 'hi', 'ml', 'te']: if f'_{code}_' in f or f'/{code}/' in root or f'\\{code}\\' in root: lang = code break records.append({ 'path': full_path, 'filename': f, 'label': 'human', 'language': lang, }) # Scan AI files if os.path.exists(raw_ai): for root, dirs, files in os.walk(raw_ai): for f in files: if f.endswith(('.flac', '.wav', '.mp3', '.ogg')): full_path = os.path.join(root, f) lang = 'unknown' for code in ['en', 'ta', 'hi', 'ml', 'te']: if f'_{code}_' in f or f'/{code}/' in root or f'\\{code}\\' in root: lang = code break # Detect TTS source from filename source = 'unknown' if 'edge' in f.lower(): source = 'edge_tts' elif 'gtts' in f.lower(): source = 'gtts' records.append({ 'path': full_path, 'filename': f, 'label': 'ai', 'language': lang, 'source': source, }) df = pd.DataFrame(records) print(f"Found {len(df)} raw audio files:") print(f" Human: {(df['label']=='human').sum()}") print(f" AI: {(df['label']=='ai').sum()}") print(f" Languages: {df['language'].value_counts().to_dict()}") return df def extract_features_batch(df, cache_path=None): """Extract v2 DSP features from all audio files.""" # Check cache if cache_path and os.path.exists(cache_path): print(f"Loading cached features from {cache_path}") return pd.read_csv(cache_path) feature_list = [] failed = [] print(f"\nExtracting v2 DSP features from {len(df)} files...") for idx, row in tqdm(df.iterrows(), total=len(df)): features = extract_all_features_v2(row['path']) if features is not None: features['filename'] = row['filename'] features['label'] = row['label'] features['language'] = row['language'] feature_list.append(features) else: failed.append(row['path']) feature_df = pd.DataFrame(feature_list) print(f" Extracted: {len(feature_list)} / {len(df)}") print(f" Failed: {len(failed)}") # Cache results if cache_path: os.makedirs(os.path.dirname(cache_path), exist_ok=True) feature_df.to_csv(cache_path, index=False) print(f" Cached to: {cache_path}") return feature_df # ============================================================ # Step 2: Prepare data for training # ============================================================ def prepare_data(feature_df): """Split features into X, y and train/test sets.""" # Feature columns = everything except metadata meta_cols = ['filename', 'label', 'language', 'source'] feature_cols = [c for c in feature_df.columns if c not in meta_cols] X = feature_df[feature_cols].values y = (feature_df['label'] == 'ai').astype(int).values languages = feature_df['language'].values # Replace any remaining NaN/Inf X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0) # Stratified split splitter = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, test_idx = next(splitter.split(X, y)) X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx] langs_test = languages[test_idx] print(f"\nData split:") print(f" Train: {len(X_train)} (human={sum(y_train==0)}, ai={sum(y_train==1)})") print(f" Test: {len(X_test)} (human={sum(y_test==0)}, ai={sum(y_test==1)})") print(f" Features: {X_train.shape[1]}") return X_train, X_test, y_train, y_test, feature_cols, langs_test # ============================================================ # Step 3: Train and compare classifiers # ============================================================ def train_classifiers(X_train, y_train, X_test, y_test): """Train multiple classifiers and return results.""" # Calculate class weights for imbalanced data n_human = sum(y_train == 0) n_ai = sum(y_train == 1) scale_pos_weight = n_human / max(n_ai, 1) class_weight = {0: 1.0, 1: scale_pos_weight} print(f"\nClass weight for AI: {scale_pos_weight:.2f} (compensating for {n_human} human vs {n_ai} ai)") candidates = {} # 1. Random Forest (with class weight) print("\n--- Random Forest ---") rf = Pipeline([ ('scaler', StandardScaler()), ('clf', RandomForestClassifier( n_estimators=200, max_depth=15, min_samples_split=5, min_samples_leaf=2, class_weight=class_weight, random_state=42, n_jobs=-1 )) ]) rf.fit(X_train, y_train) candidates['RandomForest'] = rf # 2. Gradient Boosting print("--- Gradient Boosting ---") gb = Pipeline([ ('scaler', StandardScaler()), ('clf', GradientBoostingClassifier( n_estimators=200, max_depth=6, learning_rate=0.1, min_samples_split=5, subsample=0.8, random_state=42 )) ]) gb.fit(X_train, y_train) candidates['GradientBoosting'] = gb # 3. XGBoost (if available) if HAS_XGB: print("--- XGBoost ---") xgb_clf = Pipeline([ ('scaler', StandardScaler()), ('clf', xgb.XGBClassifier( n_estimators=200, max_depth=6, learning_rate=0.1, scale_pos_weight=scale_pos_weight, eval_metric='logloss', random_state=42, n_jobs=-1 )) ]) xgb_clf.fit(X_train, y_train) candidates['XGBoost'] = xgb_clf # 4. LightGBM (if available) if HAS_LGB: print("--- LightGBM ---") lgb_clf = Pipeline([ ('scaler', StandardScaler()), ('clf', lgb.LGBMClassifier( n_estimators=200, max_depth=6, learning_rate=0.1, scale_pos_weight=scale_pos_weight, random_state=42, n_jobs=-1, verbose=-1 )) ]) lgb_clf.fit(X_train, y_train) candidates['LightGBM'] = lgb_clf # Evaluate all results = [] for name, model in candidates.items(): y_pred = model.predict(X_test) y_prob = model.predict_proba(X_test)[:, 1] acc = accuracy_score(y_test, y_pred) f1 = f1_score(y_test, y_pred) auc = roc_auc_score(y_test, y_prob) if len(np.unique(y_test)) > 1 else 0 prec = precision_score(y_test, y_pred, zero_division=0) rec = recall_score(y_test, y_pred, zero_division=0) results.append({ 'model': name, 'accuracy': acc, 'f1': f1, 'auc': auc, 'precision': prec, 'recall': rec, }) print(f" {name:20s} | Acc: {acc:.4f} | F1: {f1:.4f} | AUC: {auc:.4f} | Prec: {prec:.4f} | Rec: {rec:.4f}") results_df = pd.DataFrame(results) # Pick best by F1 best_name = results_df.loc[results_df['f1'].idxmax(), 'model'] best_model = candidates[best_name] print(f"\n* Best model: {best_name} (F1: {results_df.loc[results_df['f1'].idxmax(), 'f1']:.4f})") return best_model, best_name, candidates, results_df # ============================================================ # Step 4: Hyperparameter tuning (optional, if Optuna available) # ============================================================ def tune_best_model(X_train, y_train, X_test, y_test, best_name): """Tune the best model with Optuna.""" if not HAS_OPTUNA: print("Optuna not available, skipping hyperparameter tuning.") return None print(f"\nTuning {best_name} with Optuna (30 trials)...") n_human = sum(y_train == 0) n_ai = sum(y_train == 1) scale_pos_weight = n_human / max(n_ai, 1) def objective(trial): if best_name in ('XGBoost', 'LightGBM', 'GradientBoosting'): params = { 'n_estimators': trial.suggest_int('n_estimators', 100, 500), 'max_depth': trial.suggest_int('max_depth', 3, 12), 'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True), 'subsample': trial.suggest_float('subsample', 0.6, 1.0), 'min_child_weight': trial.suggest_int('min_child_weight', 1, 10), } if best_name == 'XGBoost' and HAS_XGB: clf = xgb.XGBClassifier( **params, scale_pos_weight=scale_pos_weight, eval_metric='logloss', random_state=42, n_jobs=-1 ) elif best_name == 'LightGBM' and HAS_LGB: clf = lgb.LGBMClassifier( **{k: v for k, v in params.items() if k != 'min_child_weight'}, scale_pos_weight=scale_pos_weight, random_state=42, n_jobs=-1, verbose=-1 ) else: clf = GradientBoostingClassifier( n_estimators=params['n_estimators'], max_depth=params['max_depth'], learning_rate=params['learning_rate'], subsample=params['subsample'], random_state=42 ) else: # Random Forest params = { 'n_estimators': trial.suggest_int('n_estimators', 100, 500), 'max_depth': trial.suggest_int('max_depth', 5, 25), 'min_samples_split': trial.suggest_int('min_samples_split', 2, 15), 'min_samples_leaf': trial.suggest_int('min_samples_leaf', 1, 8), } clf = RandomForestClassifier( **params, class_weight={0: 1.0, 1: scale_pos_weight}, random_state=42, n_jobs=-1 ) pipe = Pipeline([('scaler', StandardScaler()), ('clf', clf)]) # Cross-validation on training data scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring='f1', n_jobs=-1) return scores.mean() # Suppress Optuna logs optuna.logging.set_verbosity(optuna.logging.WARNING) study = optuna.create_study(direction='maximize') study.optimize(objective, n_trials=30, show_progress_bar=True) print(f" Best trial F1: {study.best_value:.4f}") print(f" Best params: {study.best_params}") return study.best_params # ============================================================ # Step 5: Calibrate and save # ============================================================ def calibrate_and_save(best_model, best_name, X_train, y_train, X_test, y_test, feature_cols, langs_test, results_df, tuned_params=None): """Calibrate the best model's probabilities and save everything.""" # If we have tuned params, retrain with them if tuned_params is not None: print(f"\nRetraining {best_name} with tuned parameters...") n_human = sum(y_train == 0) n_ai = sum(y_train == 1) scale_pos_weight = n_human / max(n_ai, 1) if best_name == 'XGBoost' and HAS_XGB: clf = xgb.XGBClassifier( **tuned_params, scale_pos_weight=scale_pos_weight, eval_metric='logloss', random_state=42, n_jobs=-1 ) elif best_name == 'LightGBM' and HAS_LGB: clf = lgb.LGBMClassifier( **tuned_params, scale_pos_weight=scale_pos_weight, random_state=42, n_jobs=-1, verbose=-1 ) elif best_name == 'RandomForest': clf = RandomForestClassifier( **tuned_params, class_weight={0: 1.0, 1: scale_pos_weight}, random_state=42, n_jobs=-1 ) else: clf = GradientBoostingClassifier(**tuned_params, random_state=42) best_model = Pipeline([('scaler', StandardScaler()), ('clf', clf)]) best_model.fit(X_train, y_train) # Calibrate probabilities print("\nCalibrating probabilities (isotonic)...") calibrated = CalibratedClassifierCV(best_model, method='isotonic', cv=5) calibrated.fit(X_train, y_train) # Final evaluation y_pred = calibrated.predict(X_test) y_prob = calibrated.predict_proba(X_test)[:, 1] acc = accuracy_score(y_test, y_pred) f1 = f1_score(y_test, y_pred) auc = roc_auc_score(y_test, y_prob) if len(np.unique(y_test)) > 1 else 0 print(f"\n{'='*60}") print(f"FINAL CALIBRATED MODEL RESULTS") print(f"{'='*60}") print(f" Model: {best_name} (calibrated)") print(f" Accuracy: {acc:.4f} ({acc*100:.1f}%)") print(f" F1: {f1:.4f}") print(f" AUC: {auc:.4f}") print(f"\n{classification_report(y_test, y_pred, target_names=['HUMAN', 'AI'])}") # Confusion matrix cm = confusion_matrix(y_test, y_pred) print(f"Confusion Matrix:") print(f" Predicted") print(f" HUMAN AI") print(f"Actual HUMAN {cm[0,0]:3d} {cm[0,1]:3d}") print(f"Actual AI {cm[1,0]:3d} {cm[1,1]:3d}") # Per-language accuracy lang_names = {'en': 'English', 'ta': 'Tamil', 'hi': 'Hindi', 'ml': 'Malayalam', 'te': 'Telugu'} print(f"\nPer-Language Accuracy:") for lang_code in ['en', 'ta', 'hi', 'ml', 'te']: mask = langs_test == lang_code if mask.sum() > 0: lang_acc = accuracy_score(y_test[mask], y_pred[mask]) print(f" {lang_names.get(lang_code, lang_code):12s}: {lang_acc*100:.1f}% ({mask.sum()} samples)") # Save model model_path = os.path.join(MODELS_DIR, 'dsp_model_v2.pkl') cols_path = os.path.join(MODELS_DIR, 'dsp_cols_v2.pkl') joblib.dump(calibrated, model_path) joblib.dump(feature_cols, cols_path) print(f"\n Model saved: {model_path}") print(f" Columns saved: {cols_path}") # Save results results_path = os.path.join(RESULTS_DIR, 'dsp_v2_results.json') results_data = { 'model': best_name, 'accuracy': float(acc), 'f1': float(f1), 'auc': float(auc), 'n_features': len(feature_cols), 'n_train': len(y_train), 'n_test': len(y_test), 'confusion_matrix': cm.tolist(), 'feature_columns': feature_cols, } with open(results_path, 'w') as f: json.dump(results_data, f, indent=2) # Save comparison results results_df.to_csv(os.path.join(RESULTS_DIR, 'dsp_v2_model_comparison.csv'), index=False) print(f" Results saved: {results_path}") return calibrated # ============================================================ # Main # ============================================================ def main(): print("=" * 60) print("DSP CLASSIFIER v2 — TRAINING PIPELINE") print("=" * 60) # Step 1: Scan raw data print("\n[1/5] Scanning raw data...") raw_df = scan_raw_data() if len(raw_df) == 0: print("No audio files found! Make sure data/raw/ has human/ and ai/ subdirs.") return # Step 2: Extract v2 features (with caching) print("\n[2/5] Extracting v2 DSP features...") cache_path = os.path.join(DATA_DIR, 'features', 'dsp_features_v2.csv') feature_df = extract_features_batch(raw_df, cache_path=cache_path) # Step 3: Prepare data print("\n[3/5] Preparing train/test split...") X_train, X_test, y_train, y_test, feature_cols, langs_test = prepare_data(feature_df) # Step 4: Train and compare classifiers print("\n[4/5] Training classifiers...") best_model, best_name, all_models, results_df = train_classifiers( X_train, y_train, X_test, y_test ) # Step 4b: Hyperparameter tuning (optional) tuned_params = tune_best_model(X_train, y_train, X_test, y_test, best_name) # Step 5: Calibrate and save print("\n[5/5] Calibrating and saving...") final_model = calibrate_and_save( best_model, best_name, X_train, y_train, X_test, y_test, feature_cols, langs_test, results_df, tuned_params ) print("\n" + "=" * 60) print("TRAINING COMPLETE!") print("=" * 60) if __name__ == "__main__": main()