File size: 13,545 Bytes
f1ef7e2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
"""
Model V1 error analysis.

This script analyzes:
    1. Raw 6-class emotion confusion matrix
    2. Business-level sentiment confusion matrix

It generates:
    - JSON error analysis report
    - Markdown error analysis report

Run from ml-services:

    python -m src.evaluation.model_v1_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"

EMOTION_CONFUSION_MATRIX_PATH = REPORTS_DIR / "model_v1_confusion_matrix.csv"
BUSINESS_CONFUSION_MATRIX_PATH = (
    REPORTS_DIR / "model_v1_business_sentiment_confusion_matrix.csv"
)
MODEL_V1_SUMMARY_PATH = REPORTS_DIR / "model_v1_summary.json"

ERROR_ANALYSIS_JSON_PATH = REPORTS_DIR / "model_v1_error_analysis.json"
ERROR_ANALYSIS_MD_PATH = REPORTS_DIR / "model_v1_error_analysis.md"


def normalize_matrix_label(label: str) -> str:
    """
    Normalize row/column names from confusion matrix.

    Examples:
        actual_anger -> anger
        predicted_fear -> fear
        actual_Negative/Escalated -> Negative/Escalated
    """
    return (
        str(label)
        .replace("actual_", "")
        .replace("predicted_", "")
        .strip()
    )


def load_csv_matrix(path: Path) -> pd.DataFrame:
    """
    Load a confusion matrix CSV.
    """
    if not path.exists():
        raise FileNotFoundError(f"Missing required confusion matrix: {path}")

    return pd.read_csv(path, index_col=0)


def load_json(path: Path) -> Dict:
    """
    Load JSON file.
    """
    if not path.exists():
        raise FileNotFoundError(f"Missing required JSON file: {path}")

    with path.open("r", encoding="utf-8") as file:
        return json.load(file)


def analyze_per_class_performance(confusion_df: pd.DataFrame) -> List[Dict]:
    """
    Calculate per-class performance from a confusion matrix.

    For each class:
        - support
        - correct predictions
        - recall
        - most common wrong prediction
    """
    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 the largest off-diagonal confusion pairs.

    Example:
        actual_sadness predicted_fear = 35
    """
    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],
    emotion_confusions: List[Dict],
    business_confusions: List[Dict],
) -> Dict:
    """
    Build human-readable interpretation and recommended fixes.
    """
    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 weaker than other classes and is often confused with fear or neutral.",
            "reason": "Sad, worried, tired, or low-energy speech can be acoustically similar.",
            "recommended_fix": "Add RAVDESS examples, apply light augmentation, and check sadness/fear label mapping carefully.",
        },
        {
            "issue": "Happy speech is sometimes confused with anger or negative emotion.",
            "reason": "Happy and angry speech can both be high-energy with higher pitch or louder voice.",
            "recommended_fix": "Use business-level sentiment accuracy and consider voice-feature support to distinguish positive high energy from aggressive high energy.",
        },
        {
            "issue": "Some negative emotions are confused with each other.",
            "reason": "Anger, disgust, fear, and sadness are all negative states and can overlap in vocal tone.",
            "recommended_fix": "Report both raw emotion accuracy and business sentiment accuracy, since the business use case mainly needs escalation/negative-state detection.",
        },
        {
            "issue": "The model is trained on acted emotional speech.",
            "reason": "CREMA-D is useful for labeled emotion learning but not identical to real call-center calls.",
            "recommended_fix": "Use AppTek for realistic inference/demo and manually label a small AppTek subset if accuracy on call-center-like audio is needed.",
        },
    ]

    return {
        "strongest_classes": strongest_classes,
        "weakest_classes": weakest_classes,
        "main_emotion_confusions": emotion_confusions[:5],
        "main_business_confusions": business_confusions[:5],
        "recommendations_for_model_v2": recommendations,
    }


def create_markdown_report(report: Dict) -> str:
    """
    Create a clean Markdown error analysis report.
    """
    lines = []

    lines.append("# Model V1 Error Analysis")
    lines.append("")
    lines.append("## Model Summary")
    lines.append("")
    lines.append(f"- Model version: {report['model_version']}")
    lines.append(f"- Model name: {report['model_name']}")
    lines.append(f"- Base checkpoint: {report['base_checkpoint']}")
    lines.append(f"- Dataset: {report['dataset']}")
    lines.append(f"- Raw emotion accuracy: {report['raw_emotion_accuracy']:.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 V1 performs strongly on the main call-center sentiment objective. "
        "The raw 6-class emotion accuracy is lower than the business-level sentiment "
        "accuracy because several raw emotion mistakes still fall within the same "
        "business category. For example, fear predicted as sadness is wrong at the "
        "emotion level but still correct as Negative/Escalated."
    )
    lines.append("")

    lines.append("## Recommended Improvements for Model V2")
    lines.append("")
    for item in report["interpretation"]["recommendations_for_model_v2"]:
        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. Add RAVDESS support and standardize labels.")
    lines.append("2. Train Model V2 on CREMA-D + RAVDESS.")
    lines.append("3. Track V2 experiments with MLflow.")
    lines.append("4. Compare V1 and V2 using raw emotion and business sentiment metrics.")
    lines.append("5. Use AppTek for realistic call-center inference/demo.")
    lines.append("")

    return "\n".join(lines)


def run_error_analysis() -> Dict:
    """
    Run full Model V1 error analysis and save JSON + Markdown reports.
    """
    model_summary = load_json(MODEL_V1_SUMMARY_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)

    raw_emotion_accuracy = float(model_summary["test"]["accuracy"])

    # Business accuracy from business confusion matrix.
    business_correct = int(
        sum(
            business_confusion_df.loc[row, f"predicted_{normalize_matrix_label(row)}"]
            for row in business_confusion_df.index
            if f"predicted_{normalize_matrix_label(row)}" in business_confusion_df.columns
        )
    )
    business_total = int(business_confusion_df.values.sum())
    business_accuracy = business_correct / business_total if business_total else 0.0

    report = {
        "model_version": "model_v1",
        "model_name": model_summary.get("model_name"),
        "base_checkpoint": model_summary.get("base_checkpoint"),
        "dataset": "CREMA-D",
        "raw_emotion_accuracy": raw_emotion_accuracy,
        "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,
        emotion_confusions=top_emotion_confusions,
        business_confusions=top_business_confusions,
    )

    ERROR_ANALYSIS_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)

    with ERROR_ANALYSIS_JSON_PATH.open("w", encoding="utf-8") as file:
        json.dump(report, file, indent=2)

    markdown_report = create_markdown_report(report)

    with ERROR_ANALYSIS_MD_PATH.open("w", encoding="utf-8") as file:
        file.write(markdown_report)

    return report


def main() -> None:
    report = run_error_analysis()

    print("\nModel V1 Error Analysis")
    print("-" * 70)
    print(f"Raw emotion accuracy: {report['raw_emotion_accuracy']:.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 confusions:")
    for item in report["top_emotion_confusions"][:5]:
        print(f"  - {item['actual']} -> {item['predicted']}: {item['count']}")

    print("-" * 70)
    print(f"Saved JSON report to: {ERROR_ANALYSIS_JSON_PATH}")
    print(f"Saved Markdown report to: {ERROR_ANALYSIS_MD_PATH}")


if __name__ == "__main__":
    main()