Spaces:
Running on Zero
Running on Zero
| """ | |
| Model V2 error analysis. | |
| This script analyzes: | |
| 1. Raw 6-class emotion confusion matrix | |
| 2. Business-level sentiment confusion matrix | |
| Model V2: | |
| CREMA-D + RAVDESS | |
| Run from ml-services: | |
| python -m src.evaluation.model_v2_error_analysis | |
| """ | |
| import json | |
| from pathlib import Path | |
| from typing import Dict, List | |
| import pandas as pd | |
| PROJECT_ROOT = Path(__file__).resolve().parents[3] | |
| ML_SERVICES_ROOT = PROJECT_ROOT / "ml-services" | |
| REPORTS_DIR = ML_SERVICES_ROOT / "outputs" / "reports" | |
| MODEL_REPORT_PATH = REPORTS_DIR / "model_v2_cremad_ravdess_report.json" | |
| EMOTION_CONFUSION_MATRIX_PATH = ( | |
| REPORTS_DIR / "model_v2_cremad_ravdess_confusion_matrix.csv" | |
| ) | |
| BUSINESS_CONFUSION_MATRIX_PATH = ( | |
| REPORTS_DIR / "model_v2_cremad_ravdess_business_sentiment_confusion_matrix.csv" | |
| ) | |
| ERROR_ANALYSIS_JSON_PATH = REPORTS_DIR / "model_v2_error_analysis.json" | |
| ERROR_ANALYSIS_MD_PATH = REPORTS_DIR / "model_v2_error_analysis.md" | |
| def normalize_matrix_label(label: str) -> str: | |
| """ | |
| Normalize confusion matrix row/column names. | |
| Examples: | |
| actual_anger -> anger | |
| predicted_fear -> fear | |
| actual_Negative/Escalated -> Negative/Escalated | |
| """ | |
| return ( | |
| str(label) | |
| .replace("actual_", "") | |
| .replace("predicted_", "") | |
| .strip() | |
| ) | |
| def load_json(path: Path) -> Dict: | |
| if not path.exists(): | |
| raise FileNotFoundError(f"Missing JSON file: {path}") | |
| with path.open("r", encoding="utf-8") as file: | |
| return json.load(file) | |
| def load_csv_matrix(path: Path) -> pd.DataFrame: | |
| if not path.exists(): | |
| raise FileNotFoundError(f"Missing confusion matrix: {path}") | |
| return pd.read_csv(path, index_col=0) | |
| def analyze_per_class_performance(confusion_df: pd.DataFrame) -> List[Dict]: | |
| """ | |
| Calculate recall and main confusion for each class. | |
| """ | |
| results = [] | |
| for row_label in confusion_df.index: | |
| class_name = normalize_matrix_label(row_label) | |
| row = confusion_df.loc[row_label] | |
| support = int(row.sum()) | |
| correct_column = f"predicted_{class_name}" | |
| correct = int(row.get(correct_column, 0)) | |
| recall = correct / support if support > 0 else 0.0 | |
| wrong_predictions = row.drop(labels=[correct_column], errors="ignore") | |
| wrong_predictions = wrong_predictions[wrong_predictions > 0] | |
| if not wrong_predictions.empty: | |
| most_confused_column = wrong_predictions.idxmax() | |
| most_confused_class = normalize_matrix_label(most_confused_column) | |
| most_confused_count = int(wrong_predictions.max()) | |
| else: | |
| most_confused_class = None | |
| most_confused_count = 0 | |
| results.append( | |
| { | |
| "class_name": class_name, | |
| "support": support, | |
| "correct": correct, | |
| "recall": float(recall), | |
| "most_confused_with": most_confused_class, | |
| "most_confused_count": most_confused_count, | |
| } | |
| ) | |
| results.sort(key=lambda item: item["recall"], reverse=True) | |
| return results | |
| def extract_top_confusions(confusion_df: pd.DataFrame, top_n: int = 10) -> List[Dict]: | |
| """ | |
| Extract largest off-diagonal mistakes. | |
| """ | |
| confusions = [] | |
| for row_label in confusion_df.index: | |
| actual_class = normalize_matrix_label(row_label) | |
| for column_label in confusion_df.columns: | |
| predicted_class = normalize_matrix_label(column_label) | |
| if actual_class == predicted_class: | |
| continue | |
| count = int(confusion_df.loc[row_label, column_label]) | |
| if count > 0: | |
| confusions.append( | |
| { | |
| "actual": actual_class, | |
| "predicted": predicted_class, | |
| "count": count, | |
| } | |
| ) | |
| confusions.sort(key=lambda item: item["count"], reverse=True) | |
| return confusions[:top_n] | |
| def build_interpretation( | |
| emotion_performance: List[Dict], | |
| top_emotion_confusions: List[Dict], | |
| top_business_confusions: List[Dict], | |
| ) -> Dict: | |
| weakest_classes = sorted( | |
| emotion_performance, | |
| key=lambda item: item["recall"], | |
| )[:3] | |
| strongest_classes = sorted( | |
| emotion_performance, | |
| key=lambda item: item["recall"], | |
| reverse=True, | |
| )[:3] | |
| recommendations = [ | |
| { | |
| "issue": "Sadness is still confused with fear and neutral.", | |
| "reason": "Sadness, fear, and low-energy neutral speech can overlap acoustically.", | |
| "recommended_fix": "Try augmentation and unfreezing deeper Wav2Vec2 layers. Also evaluate CREMA-D and RAVDESS separately to see which dataset causes this confusion.", | |
| }, | |
| { | |
| "issue": "Positive/Calm is still sometimes predicted as Negative/Escalated.", | |
| "reason": "Happy speech can be high-energy and acoustically close to anger.", | |
| "recommended_fix": "Tune thresholds for business sentiment and consider adding acoustic feature support for positive high-energy vs aggressive high-energy speech.", | |
| }, | |
| { | |
| "issue": "Raw accuracy improved only slightly after adding RAVDESS.", | |
| "reason": "RAVDESS improves data diversity, but the current training strategy may not fully adapt the model.", | |
| "recommended_fix": "Run controlled hyperparameter experiments and test partial unfreezing instead of only frozen feature encoder training.", | |
| }, | |
| { | |
| "issue": "Combined test accuracy may hide dataset-specific weakness.", | |
| "reason": "CREMA-D and RAVDESS have different speakers, recording conditions, and acting styles.", | |
| "recommended_fix": "Evaluate Model V2 separately on CREMA-D test and RAVDESS test before deciding the next training experiment.", | |
| }, | |
| ] | |
| return { | |
| "strongest_classes": strongest_classes, | |
| "weakest_classes": weakest_classes, | |
| "main_emotion_confusions": top_emotion_confusions[:5], | |
| "main_business_confusions": top_business_confusions[:5], | |
| "recommendations_for_model_v3": recommendations, | |
| } | |
| def create_markdown_report(report: Dict) -> str: | |
| lines = [] | |
| lines.append("# Model V2 Error Analysis") | |
| lines.append("") | |
| lines.append("## Model Summary") | |
| lines.append("") | |
| lines.append(f"- Model version: {report['model_version']}") | |
| lines.append(f"- Run name: {report['run_name']}") | |
| lines.append(f"- Dataset source: {report['dataset_source']}") | |
| lines.append(f"- Raw emotion accuracy: {report['raw_emotion_accuracy']:.4f}") | |
| lines.append(f"- Raw emotion macro F1: {report['raw_emotion_macro_f1']:.4f}") | |
| lines.append(f"- Business sentiment accuracy: {report['business_sentiment_accuracy']:.4f}") | |
| lines.append("") | |
| lines.append("## Strongest Emotion Classes") | |
| lines.append("") | |
| for item in report["interpretation"]["strongest_classes"]: | |
| lines.append( | |
| f"- {item['class_name']}: recall={item['recall']:.4f} " | |
| f"({item['correct']}/{item['support']} correct)" | |
| ) | |
| lines.append("") | |
| lines.append("## Weakest Emotion Classes") | |
| lines.append("") | |
| for item in report["interpretation"]["weakest_classes"]: | |
| confused_with = item["most_confused_with"] or "none" | |
| lines.append( | |
| f"- {item['class_name']}: recall={item['recall']:.4f} " | |
| f"({item['correct']}/{item['support']} correct), " | |
| f"most confused with {confused_with} " | |
| f"({item['most_confused_count']} cases)" | |
| ) | |
| lines.append("") | |
| lines.append("## Top Emotion Confusions") | |
| lines.append("") | |
| lines.append("| Actual Emotion | Predicted Emotion | Count |") | |
| lines.append("|---|---:|---:|") | |
| for item in report["top_emotion_confusions"]: | |
| lines.append(f"| {item['actual']} | {item['predicted']} | {item['count']} |") | |
| lines.append("") | |
| lines.append("## Top Business Sentiment Confusions") | |
| lines.append("") | |
| lines.append("| Actual Sentiment | Predicted Sentiment | Count |") | |
| lines.append("|---|---:|---:|") | |
| for item in report["top_business_confusions"]: | |
| lines.append(f"| {item['actual']} | {item['predicted']} | {item['count']} |") | |
| lines.append("") | |
| lines.append("## Interpretation") | |
| lines.append("") | |
| lines.append( | |
| "Model V2 improves raw emotion accuracy slightly compared with Model V1, " | |
| "but the remaining errors show that some emotional states are still acoustically close. " | |
| "The largest issue remains confusion among sadness, fear, and some neutral samples. " | |
| "At the business level, the most important remaining issue is Positive/Calm being " | |
| "misclassified as Negative/Escalated." | |
| ) | |
| lines.append("") | |
| lines.append("## Recommended Improvements for Model V3") | |
| lines.append("") | |
| for item in report["interpretation"]["recommendations_for_model_v3"]: | |
| lines.append(f"### {item['issue']}") | |
| lines.append("") | |
| lines.append(f"- Reason: {item['reason']}") | |
| lines.append(f"- Recommended fix: {item['recommended_fix']}") | |
| lines.append("") | |
| lines.append("## Next Steps") | |
| lines.append("") | |
| lines.append("1. Evaluate Model V2 separately on CREMA-D test and RAVDESS test.") | |
| lines.append("2. Run controlled hyperparameter tuning experiments.") | |
| lines.append("3. Try partial unfreezing of Wav2Vec2 layers.") | |
| lines.append("4. Add training-only audio augmentation.") | |
| lines.append("5. Use AppTek for realistic call-center inference/demo.") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def run_error_analysis() -> Dict: | |
| model_report = load_json(MODEL_REPORT_PATH) | |
| emotion_confusion_df = load_csv_matrix(EMOTION_CONFUSION_MATRIX_PATH) | |
| business_confusion_df = load_csv_matrix(BUSINESS_CONFUSION_MATRIX_PATH) | |
| emotion_performance = analyze_per_class_performance(emotion_confusion_df) | |
| business_performance = analyze_per_class_performance(business_confusion_df) | |
| top_emotion_confusions = extract_top_confusions(emotion_confusion_df, top_n=10) | |
| top_business_confusions = extract_top_confusions(business_confusion_df, top_n=10) | |
| business_correct = 0 | |
| for row in business_confusion_df.index: | |
| label = normalize_matrix_label(row) | |
| column = f"predicted_{label}" | |
| if column in business_confusion_df.columns: | |
| business_correct += int(business_confusion_df.loc[row, column]) | |
| business_total = int(business_confusion_df.values.sum()) | |
| business_accuracy = business_correct / business_total if business_total else 0.0 | |
| report = { | |
| "model_version": "model_v2", | |
| "run_name": model_report.get("run_name"), | |
| "dataset_source": model_report.get("dataset_source"), | |
| "raw_emotion_accuracy": float(model_report["test"]["accuracy"]), | |
| "raw_emotion_macro_f1": float(model_report["test"]["macro_f1"]), | |
| "business_sentiment_accuracy": float(business_accuracy), | |
| "emotion_per_class_performance": emotion_performance, | |
| "business_per_class_performance": business_performance, | |
| "top_emotion_confusions": top_emotion_confusions, | |
| "top_business_confusions": top_business_confusions, | |
| } | |
| report["interpretation"] = build_interpretation( | |
| emotion_performance=emotion_performance, | |
| top_emotion_confusions=top_emotion_confusions, | |
| top_business_confusions=top_business_confusions, | |
| ) | |
| with ERROR_ANALYSIS_JSON_PATH.open("w", encoding="utf-8") as file: | |
| json.dump(report, file, indent=2) | |
| markdown = create_markdown_report(report) | |
| with ERROR_ANALYSIS_MD_PATH.open("w", encoding="utf-8") as file: | |
| file.write(markdown) | |
| return report | |
| def main() -> None: | |
| report = run_error_analysis() | |
| print("\nModel V2 Error Analysis") | |
| print("-" * 80) | |
| print(f"Dataset source: {report['dataset_source']}") | |
| print(f"Raw emotion accuracy: {report['raw_emotion_accuracy']:.4f}") | |
| print(f"Raw emotion macro F1: {report['raw_emotion_macro_f1']:.4f}") | |
| print(f"Business sentiment accuracy: {report['business_sentiment_accuracy']:.4f}") | |
| print("\nStrongest classes:") | |
| for item in report["interpretation"]["strongest_classes"]: | |
| print(f" - {item['class_name']}: recall={item['recall']:.4f}") | |
| print("\nWeakest classes:") | |
| for item in report["interpretation"]["weakest_classes"]: | |
| print( | |
| f" - {item['class_name']}: recall={item['recall']:.4f}, " | |
| f"most confused with {item['most_confused_with']}" | |
| ) | |
| print("\nTop emotion confusions:") | |
| for item in report["top_emotion_confusions"][:5]: | |
| print(f" - {item['actual']} -> {item['predicted']}: {item['count']}") | |
| print("\nTop business confusions:") | |
| for item in report["top_business_confusions"][:5]: | |
| print(f" - {item['actual']} -> {item['predicted']}: {item['count']}") | |
| print("-" * 80) | |
| print(f"Saved JSON report to: {ERROR_ANALYSIS_JSON_PATH}") | |
| print(f"Saved Markdown report to: {ERROR_ANALYSIS_MD_PATH}") | |
| if __name__ == "__main__": | |
| main() |