Commit ยท
c857b85
0
Parent(s):
Deploy from GitHub 2026-04-23T03:56:31Z
Browse filesThis view is limited to 50 files because it contains too many changes. ย See raw diff
- .dockerignore +32 -0
- .gitattributes +1 -0
- Dockerfile +39 -0
- README.md +334 -0
- VERSION +1 -0
- config.yaml +87 -0
- data/meld_test/01_angry_fight.wav +3 -0
- data/meld_test/02_happy_loving.wav +3 -0
- data/meld_test/03_sad_emotional.wav +3 -0
- data/meld_test/04_surprise_shock.wav +3 -0
- data/meld_test/05_fear_anxiety.wav +3 -0
- data/meld_test/06_disgust_annoyance.wav +3 -0
- data/meld_test/07_bittersweet.wav +3 -0
- data/meld_test/08_calm_daily.wav +3 -0
- data/meld_test/09_opposite_emotions.wav +3 -0
- data/meld_test/README.md +53 -0
- data/meld_test/ground_truth.json +772 -0
- railway.toml +9 -0
- requirements-deploy.txt +31 -0
- requirements.txt +17 -0
- scripts/add_ravdess_to_english_manifest.py +76 -0
- scripts/asr_savee_disgust_surprise.py +50 -0
- scripts/benchmark_emotion2vec.py +513 -0
- scripts/benchmark_ser_models.py +799 -0
- scripts/build_english_fusion_manifest.py +135 -0
- scripts/build_meld_test_sets.py +373 -0
- scripts/cache_models.py +128 -0
- scripts/convert_to_onnx.py +0 -0
- scripts/eval_audio_on_subset.py +138 -0
- scripts/evaluate_emotion2vec_english.py +194 -0
- scripts/export_lora_onnx.py +269 -0
- scripts/optimize_fusion_weights.py +618 -0
- scripts/prepare_aihub_test_subset.py +421 -0
- scripts/prepare_dataset.py +0 -0
- scripts/prepare_lora_dataset.py +875 -0
- scripts/prepare_meld_fusion_data.py +153 -0
- scripts/prepare_ravdess.py +220 -0
- scripts/preprocess_phone_audio.py +175 -0
- scripts/quantize_model.py +0 -0
- scripts/run_pipeline.py +121 -0
- scripts/test_20hours_e2e_server.py +218 -0
- scripts/test_english_e2e.py +239 -0
- scripts/test_meld_e2e_server.py +253 -0
- scripts/train_emotion2vec.py +0 -0
- scripts/train_fusion_weights.py +297 -0
- scripts/train_kcelectra.py +0 -0
- scripts/train_lora_emotion2vec.py +749 -0
- scripts/train_lora_kcelectra.py +402 -0
- scripts/train_whisper_emotion_head.py +219 -0
- scripts/validate_text_emotion_english.py +137 -0
.dockerignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Large data directories
|
| 2 |
+
data/
|
| 3 |
+
venv/
|
| 4 |
+
.venv/
|
| 5 |
+
|
| 6 |
+
# Frontend (not needed for API server)
|
| 7 |
+
app/
|
| 8 |
+
node_modules/
|
| 9 |
+
|
| 10 |
+
# Dev files
|
| 11 |
+
notebooks/
|
| 12 |
+
.git/
|
| 13 |
+
.github/
|
| 14 |
+
__pycache__/
|
| 15 |
+
*.pyc
|
| 16 |
+
|
| 17 |
+
# Archives
|
| 18 |
+
*.zip
|
| 19 |
+
|
| 20 |
+
# IDE
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
| 23 |
+
|
| 24 |
+
# Claude/gstack
|
| 25 |
+
.claude/
|
| 26 |
+
.gstack/
|
| 27 |
+
.superpowers/
|
| 28 |
+
|
| 29 |
+
# Docs (not needed at runtime)
|
| 30 |
+
docs/
|
| 31 |
+
*.md
|
| 32 |
+
!requirements-deploy.txt
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
# System deps for audio processing (librosa, soundfile)
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
libsndfile1 \
|
| 6 |
+
ffmpeg \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install torch + torchaudio CPU-only (saves ~2.3GB vs full CUDA build)
|
| 12 |
+
RUN pip install --no-cache-dir \
|
| 13 |
+
torch torchaudio --index-url https://download.pytorch.org/whl/cpu
|
| 14 |
+
|
| 15 |
+
# Force cache invalidation
|
| 16 |
+
ARG CACHEBUST=6
|
| 17 |
+
# Install remaining dependencies
|
| 18 |
+
COPY requirements-deploy.txt .
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements-deploy.txt
|
| 20 |
+
|
| 21 |
+
# Copy source code
|
| 22 |
+
COPY src/ src/
|
| 23 |
+
COPY config.yaml .
|
| 24 |
+
COPY scripts/cache_models.py scripts/cache_models.py
|
| 25 |
+
|
| 26 |
+
# Pre-download ML models at build time (avoids cold-start downloads)
|
| 27 |
+
ARG HF_TOKEN
|
| 28 |
+
ENV HF_TOKEN=${HF_TOKEN}
|
| 29 |
+
RUN python scripts/cache_models.py
|
| 30 |
+
|
| 31 |
+
# Create data directory for SQLite + uploads
|
| 32 |
+
RUN mkdir -p data/samples
|
| 33 |
+
|
| 34 |
+
# HF Spaces port
|
| 35 |
+
ENV PORT=7860
|
| 36 |
+
|
| 37 |
+
EXPOSE 7860
|
| 38 |
+
|
| 39 |
+
CMD ["uvicorn", "src.stage4.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: UsTwo API
|
| 3 |
+
emoji: ๐
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# UsTwo โ your characters react to your calls
|
| 12 |
+
|
| 13 |
+
> **CMU 11-775 Large Scale Multimedia Analysis** โ Team Project
|
| 14 |
+
> 3-person team: Juhyun (PO / Research) ยท Seungjae (ML) ยท Youngkyun (App)
|
| 15 |
+
|
| 16 |
+
UsTwo takes a recorded phone call between two people (couple, friends, family) and runs **multimodal analysis (audio + text)** to understand how each speaker felt. It then produces three things inside a **React Native + FastAPI** mobile app: a **character reaction scene**, a **growing emotion garden**, and an **LLM-written recap card**.
|
| 17 |
+
|
| 18 |
+
The goal isn't just emotion classification. It's to visualize *"what kind of moment did these two share on this call?"*
|
| 19 |
+
|
| 20 |
+
<p align="center">
|
| 21 |
+
<img src="docs/images/app-home.png" width="260" alt="Home" />
|
| 22 |
+
<img src="docs/images/app-recap.png" width="260" alt="Recap card" />
|
| 23 |
+
<img src="docs/images/app-history.png" width="260" alt="History" />
|
| 24 |
+
</p>
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## At a glance
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
[call recording .wav/.m4a]
|
| 32 |
+
โ
|
| 33 |
+
โผ
|
| 34 |
+
โโโโโโโโโโโโโโโโโโโโโ Stage 1 (Seungjae) โโโโโโโโโโโโโโโโโโโโโ
|
| 35 |
+
โ pyannote 4.x (3.1) โ WhisperX large-v3-turbo INT8 โ ko/en โ
|
| 36 |
+
โ speaker diarization ASR + forced alignment LID โ
|
| 37 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 38 |
+
โ segments: [{speaker, start, end, text, lang}]
|
| 39 |
+
โผ
|
| 40 |
+
โโโโโโโโโโโโโโโโโโโโโ Stage 2 (Seungjae) โโโโโโโโโโโโโโโโโโโโโ
|
| 41 |
+
โ emotion2vec LoRA (ONNX) + KcELECTRA LoRA (ko) / DistilRoBERTa โ
|
| 42 |
+
โ audio emotion (7-class) text emotion (7-class ko / 7-class en) โ
|
| 43 |
+
โ โ
|
| 44 |
+
โ fusion: per-language, per-class trained weights (v2) โ
|
| 45 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 46 |
+
โ per-speaker emotion distribution
|
| 47 |
+
โผ
|
| 48 |
+
โโโโโโโโโโโโโโโโโโโโโ Stage 3 (Youngkyun) โโโโโโโโโโโโโโโโโโโโ
|
| 49 |
+
โ character_mapping + garden_logic + recap_generator โ
|
| 50 |
+
โ 9 pair interactions 5 levels ยท 4 moods Claude LLM โ
|
| 51 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 52 |
+
โ
|
| 53 |
+
โผ
|
| 54 |
+
โโโโโโโโโโโโโโโโโโโโโ Stage 4 (Youngkyun) โโโโโโโโโโโโโโโโโโโโ
|
| 55 |
+
โ FastAPI + SQLite โ React Native (Expo) โ
|
| 56 |
+
โ 6 endpoints, async expo-router ยท SVG โ
|
| 57 |
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
**End-to-end latency:** a 1-minute call finishes in about 2 minutes on the HF Spaces CPU deployment.
|
| 61 |
+
**Live server:** `https://bbbakery-ustwo-api.hf.space`
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## Demo โ the garden grows
|
| 66 |
+
|
| 67 |
+
The emotion garden levels up as positive-ratio interactions accumulate. Level 1 shows just two seedlings. After many positive calls, Level 5 is a full bloom with flowers, trees, and creatures.
|
| 68 |
+
|
| 69 |
+
<p align="center">
|
| 70 |
+
<img src="docs/images/garden-level-1.png" width="260" alt="Level 1 โ seedling" />
|
| 71 |
+
<img src="docs/images/garden-level-5.png" width="260" alt="Level 5 โ full bloom" />
|
| 72 |
+
</p>
|
| 73 |
+
|
| 74 |
+
| Level | Threshold (cumulative interactions) | Visual |
|
| 75 |
+
|-------|-------------------------------------|--------|
|
| 76 |
+
| 1 | 0โ2 | Two seedlings |
|
| 77 |
+
| 2 | 3โ7 | Grass + small flowers |
|
| 78 |
+
| 3 | 8โ14 | Trees + many flowers |
|
| 79 |
+
| 4 | 15โ24 | Lush garden + creatures |
|
| 80 |
+
| 5 | 25+ | Sunset sky + butterflies, rabbits + full bloom |
|
| 81 |
+
|
| 82 |
+
A **mood overlay** (`happy` ยท `neglected` ยท `recovering` ยท `conflict`) is layered on top, decided by the recent positive/negative ratio and the days since the last interaction. A healthy garden that's been ignored for 5+ days turns `neglected`; a recent call with heavy negative emotion pushes it to `conflict`.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Stage 1โ2 โ ML Pipeline (Seungjae)
|
| 87 |
+
|
| 88 |
+
### Stage 1: Diarization + ASR
|
| 89 |
+
|
| 90 |
+
| Component | Model | Config |
|
| 91 |
+
|-----------|-------|--------|
|
| 92 |
+
| VAD | Silero VAD | onset 0.5, min_speech 0.25s |
|
| 93 |
+
| Diarization | pyannote-audio 4.x (3.1) | HF token required |
|
| 94 |
+
| ASR | Faster-Whisper (large-v3-turbo) | INT8 quantized |
|
| 95 |
+
| Forced alignment | WhisperX wav2vec2 | per-word timestamps |
|
| 96 |
+
| Language ID | Whisper auto-detect + SenseVoice-Small | ko / en / unknown |
|
| 97 |
+
|
| 98 |
+
**Output:** `Stage1Output` โ `segments: [{speaker, start, end, text, lang}]`, `processing_info`, `models`. Entry point: `src/stage1/process.py`.
|
| 99 |
+
|
| 100 |
+
### Stage 2: Emotion Recognition (bilingual)
|
| 101 |
+
|
| 102 |
+
| Channel | Model | Classes |
|
| 103 |
+
|---------|-------|---------|
|
| 104 |
+
| Audio emotion | `emotion2vec_plus_base` + **LoRA fine-tuned (ONNX)** | 7: neutral, joy, sadness, anger, surprise, fear, disgust |
|
| 105 |
+
| Text emotion โ Korean | **KcELECTRA-base-v2022 + PEFT LoRA (ONNX)** | 7: neutral, joy, sadness, anger, surprise, fear, disgust |
|
| 106 |
+
| Text emotion โ English | DistilRoBERTa (j-hartmann/emotion-english, zero-shot) | 7 Ekman + neutral |
|
| 107 |
+
| Fusion | **Per-language, per-class weights trained via gradient descent** | EMOTION_FUSION_WEIGHTS_KO / _EN |
|
| 108 |
+
|
| 109 |
+
### Evaluation โ English (RAVDESS, n=2,880)
|
| 110 |
+
|
| 111 |
+
| Condition | Accuracy | Macro F1 | Latency |
|
| 112 |
+
|-----------|----------|----------|---------|
|
| 113 |
+
| Clean (studio) | **93.2%** | **0.932** | 250 ms |
|
| 114 |
+
| Phone (PSTN simulation, 300โ3400 Hz band-limit) | **71.5%** | **0.710** | 242 ms |
|
| 115 |
+
| Degradation | โ21.7 pp | โ0.222 | โ |
|
| 116 |
+
|
| 117 |
+
- **Phone-robust emotions (F1 > 0.80):** anger (0.836), surprise (0.856) โ high-energy acoustic cues survive the band-limit.
|
| 118 |
+
- **Phone-degraded (largest drop):** joy (0.946 โ 0.585, โ0.361), sadness (0.868 โ 0.559, โ0.309) โ subtler pitch/timbre cues degrade hardest.
|
| 119 |
+
- **Text emotion sanity check (DistilRoBERTa on j-hartmann's held-out set):** 95.2% accuracy.
|
| 120 |
+
|
| 121 |
+
Full report: [`docs/stage2/english-evaluation-report.md`](docs/stage2/english-evaluation-report.md)
|
| 122 |
+
|
| 123 |
+
### Evaluation โ Korean (KcELECTRA fine-tuning)
|
| 124 |
+
|
| 125 |
+
- Fine-tuned on the AI Hub *๊ฐ์ฑ ๋ํ ๋ง๋ญ์น* (emotion dialogue corpus) on Colab GPU.
|
| 126 |
+
- Macro F1: **0.20 (base) โ 0.65 (fine-tuned)**, a 3.25ร improvement.
|
| 127 |
+
- Discovered an unlabeled "joy" cluster in the dataset and applied class weighting to recover minority classes.
|
| 128 |
+
|
| 129 |
+
### Fusion weight training (v2, 2026-04-19)
|
| 130 |
+
|
| 131 |
+
Replaced greedy per-emotion grid search with **PyTorch gradient descent** training `w_a = sigmoid(ฮฑ)` per class, optimized jointly with cross-entropy + L2 regularization on a held-out val split.
|
| 132 |
+
|
| 133 |
+
| Language | Training data | Samples | Val Macro F1 (v1 โ v2) |
|
| 134 |
+
|----------|---------------|---------|------------------------|
|
| 135 |
+
| English | JL-Corpus + SAVEE + MELD + RAVDESS phone | 2,821 | 0.6295 โ **0.7596 (+12.67%p)** |
|
| 136 |
+
| Korean | AI Hub 263 val | 1,294 | 0.8736 โ **0.8748 (tie)** |
|
| 137 |
+
|
| 138 |
+
Biggest single win: English `fear` F1 rose from **0.04 โ 0.67** (greedy's `audio_w=0.00` was a local trap). Per-language weight tables: `src/common/constants.py` (`EMOTION_FUSION_WEIGHTS_KO` / `_EN`). Details: [`docs/stage2/fusion-weights-english-grid-search.md`](docs/stage2/fusion-weights-english-grid-search.md).
|
| 139 |
+
|
| 140 |
+
### End-to-end test โ MELD (Friends TV dialogue)
|
| 141 |
+
|
| 142 |
+
We built 8 scenario WAVs from MELD (anger, joy, sadness, surprise, fear, bittersweet, annoyance, calm) and ran them through the deployed server.
|
| 143 |
+
|
| 144 |
+
| Metric | Result |
|
| 145 |
+
|--------|--------|
|
| 146 |
+
| Pipeline success | **8 / 8** files completed Stage 1 โ 2 โ 3 |
|
| 147 |
+
| Exact top-1 emotion label match | **7 / 7** (one tie) |
|
| 148 |
+
| Average processing time | ~2 min / file (HF Spaces CPU) |
|
| 149 |
+
|
| 150 |
+
### End-to-end test โ 20Hours Korean demo (2026-04-20)
|
| 151 |
+
|
| 152 |
+
Companion Korean E2E set curated from the 20Hours Korean Conversational Speech dataset (M-F pairs only). 7 scenarios @ ~1 min each for live demo.
|
| 153 |
+
|
| 154 |
+
| Metric | Result |
|
| 155 |
+
|--------|--------|
|
| 156 |
+
| Pipeline success | **7 / 7** files completed Stage 1 โ 2 โ 3 |
|
| 157 |
+
| Intended-emotion match (one speaker) | **4 / 7** |
|
| 158 |
+
| Source | `data/20hours_test/` + `scripts/test_20hours_e2e_server.py` |
|
| 159 |
+
|
| 160 |
+
Note: 20Hours source is ASR-training data without emotion labels โ `ground_truth.json` lists *intended demo emotions* (for graph visualization), not ground-truth annotations.
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Stage 3โ4 โ App + Server (Youngkyun)
|
| 165 |
+
|
| 166 |
+
### Stage 3: Reaction ยท Garden ยท Recap
|
| 167 |
+
|
| 168 |
+
`src/stage3/process.py` takes the Stage 2 output and runs three independent modules:
|
| 169 |
+
|
| 170 |
+
| Module | Role | Key logic |
|
| 171 |
+
|--------|------|-----------|
|
| 172 |
+
| `character_mapping.py` | Buckets each speaker's 7-class emotion into 4 moods (up / calm / down / tense) and looks up the pair cell in a 4ร4 matrix โ one of 9 pair states + a giver role | `joy โ up`, `anger โ tense`, `surprise/fear/disgust` resolved via residual distribution; `up ร down โ comforting (giver=A)`, `tense ร tense โ back_turned`, `calm ร calm โ idle`, ... |
|
| 173 |
+
| `character_mapping.py` (intensity) | Emits a `CharacterReaction.intensity` in `{1, 2, 3}` used by the app for healing-cycle thresholds | Default 2 โ 3 cycles to heal; 1 โ 4 cycles; 3 โ 2 cycles |
|
| 174 |
+
| `garden_logic.py` | Computes growth delta (0โ3) from call quality | `positive_ratio โฅ 0.5` โ +3, `pos โฅ 0.3 && neg < 0.3` โ +2, `neg โฅ 0.5` โ 0 |
|
| 175 |
+
| `recap_generator.py` | Generates the narrative recap card via the Claude API | System prompt requires one concrete hook from the transcript (topic, decision, shared joke) + a light garden-voice framing โ titles are call-specific, not combo templates. Rule-based template fallback when no API key. |
|
| 176 |
+
|
| 177 |
+
**Mood resolution:** `neg โฅ 0.5` โ `conflict`; `neg โฅ 0.3 && pos < 0.3` โ `recovering`; `level โค 1 && pos < 0.3` โ `neglected`; otherwise `happy`. **Level only goes up** โ tough calls shift the tint, never the count.
|
| 178 |
+
|
| 179 |
+
**Confidence gate:** a speaker whose top emotion probability is below 0.5 is demoted to `calm` for pair-state lookup. The 7-class mood chip on the Results screen can therefore differ from the pair-state particle โ the chip shows the dominant label, the particle shows the matrix cell after gating.
|
| 180 |
+
|
| 181 |
+
### Stage 4: FastAPI backend
|
| 182 |
+
|
| 183 |
+
- **Framework:** FastAPI + SQLAlchemy + SQLite (local) ยท 4 tables: `calls`, `analysis_results`, `checkins`, `garden_state`.
|
| 184 |
+
- **Async pipeline:** `POST /api/upload` โ `POST /api/analyze?call_id=X` returns 202 Accepted + a background thread, and the client polls `GET /api/calls/{id}`.
|
| 185 |
+
- **Mock path:** drop `data/samples/{call_id}_stage2.json` and the API skips Stage 1โ2, running only Stage 3 โ useful for E2E testing without the heavy ML deps.
|
| 186 |
+
- **Endpoints (6):** `/api/upload`, `/api/analyze`, `/api/calls`, `/api/calls/{id}`, `/api/checkins`, `/api/garden`. `/api/calls` extracts `recap_card.title` from the stored Stage 3 JSON so the Home and History feeds can headline each card with a distinct title.
|
| 187 |
+
- **Tests:** 12 API tests on in-memory SQLite via pytest.
|
| 188 |
+
|
| 189 |
+
### React Native (Expo) app
|
| 190 |
+
|
| 191 |
+
**Router:** `expo-router` โ 3 tabs (`Us`, `History`, `Settings`) plus modal routes (`checkin`, `results/[callId]`).
|
| 192 |
+
|
| 193 |
+
| Screen | Description |
|
| 194 |
+
|--------|-------------|
|
| 195 |
+
| <img src="docs/images/app-onboarding.png" width="180" /> | **Onboarding** โ paper-deck intro ("A garden for two") that sets the metaphor before the first call lands |
|
| 196 |
+
| <img src="docs/images/app-home.png" width="180" /> | **Home (`Us`)** โ live character scene + garden, recent-call feed, mailbox entry point |
|
| 197 |
+
| <img src="docs/images/app-bloom.png" width="180" /> | **Seed bloom alert** โ level-up moment; fires when the interaction count crosses a garden threshold, offering `View` / `Later` |
|
| 198 |
+
| <img src="docs/images/app-checkin.png" width="180" /> | **Check-in** โ 2-step prompt: my mood, then my guess for the partner's mood (empathic accuracy) |
|
| 199 |
+
| <img src="docs/images/app-results.png" width="180" /> | **Results โ emotion analysis** โ `My mood` / `Their mood` chips, suggestion line in the garden-voice, `Emotional Landscape` wave (uplifting โ heavy), and `Moments that mattered` per-speaker slices |
|
| 200 |
+
| <img src="docs/images/app-recap.png" width="180" /> | **Results โ recap card** โ call-specific LLM title + narrative + highlights + per-call `Our Garden` delta, followed by the `Was this accurate?` thumbs-up/down feedback loop |
|
| 201 |
+
| <img src="docs/images/app-history.png" width="180" /> | **History** โ `Our Emotional Flow` chart (Me vs Partner), garden delta summary, recent moment cards headlined by the call-specific recap title |
|
| 202 |
+
| <img src="docs/images/app-settings.png" width="180" /> | **Settings** โ language toggle (ko/en), developer mode, dev tools |
|
| 203 |
+
|
| 204 |
+
**Character animation layers** (all built on `react-native-reanimated` + `react-native-svg`):
|
| 205 |
+
|
| 206 |
+
1. **Idle breathing** โ per-mood rhythm profile (up / calm / down / tense), uniform `scaleAmp` 1.02, micro-bob, micro-sway (1.4s cycle).
|
| 207 |
+
2. **Eye blink** โ 2.8โ5.5s interval, 15% chance of a double-blink.
|
| 208 |
+
3. **Emotion transition** โ joy = bounce + arms **raised**, sadness = sink + drooping arms, anger / disgust = crossed arms (no tilt), surprise = pop, fear = shrink. Body rotation is globally forbidden โ emotion reads via face, pupil, body pose, and idle rhythm instead.
|
| 209 |
+
4. **Pair-state body pose** โ `comforting` giver moves 60% closer with no tilt; receiver gets a gentle sag + `pupilOffset` (head-lowering effect on eyes).
|
| 210 |
+
5. **Pair-state particle signature** โ one preset per 4ร4 matrix cell:
|
| 211 |
+
- `comforting` โ staged echo: 1 heart (giver โ receiver) + 1 bubble dot + 1 translucent heart (bezier engine, `useParticles`).
|
| 212 |
+
- `dancing` โ 1 music note rising above the heads.
|
| 213 |
+
- `cheering` / `listening` / `sitting_together` / `defusing` โ yellow sparkle cluster.
|
| 214 |
+
- `tension` / `back_turned` โ grey sigh cloud.
|
| 215 |
+
- `idle` (calm ร calm) โ silent; the couple just wanders.
|
| 216 |
+
6. **Generalised healing** โ every giver-present cell (comforting, listening, defusing, cheering) counts interaction cycles; when the intensity-scaled threshold is hit the receiver transitions to neutral. If the giver's solo state was negative-caring (fear / anger / sadness / disgust), they heal too โ no one is left behind.
|
| 217 |
+
7. **Tap interaction** โ spring bounce + floating hearts / sparkles (`FloatingHearts` component).
|
| 218 |
+
8. **Wander** โ characters roam between pre-validated waypoints (3โ8s move, 2โ5s pause), with direction-aware facing (scaleX flip for left/right, back-view for upward movement).
|
| 219 |
+
|
| 220 |
+
**Garden rendering (SVG):** Sky, Ground, Trees (level-specific types and counts), Flowers (distributed across four quadrants), Creatures (butterflies, rabbits, birds โ Level 4 and above).
|
| 221 |
+
|
| 222 |
+
**i18n:** a custom `LocaleContext` drives Korean/English switching with `@ustwo/locale` persisted to AsyncStorage.
|
| 223 |
+
|
| 224 |
+
### State persistence
|
| 225 |
+
|
| 226 |
+
| Store | Data | Key |
|
| 227 |
+
|-------|------|-----|
|
| 228 |
+
| SQLite (server) | calls, analysis_results, checkins, garden_state | โ |
|
| 229 |
+
| AsyncStorage (app) | locale, dev_mode, garden (interactionCount + lastPositiveRatio + lastInteractionDate), checkins (local cache) | `@ustwo/*` |
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Repository map
|
| 234 |
+
|
| 235 |
+
```
|
| 236 |
+
src/stage1/ Speaker diarization + ASR (Seungjae)
|
| 237 |
+
src/stage2/ Audio + text emotion + fusion (Seungjae)
|
| 238 |
+
src/stage3/ Character, garden, recap (Youngkyun)
|
| 239 |
+
src/stage4/ FastAPI + SQLite + orchestration (Youngkyun)
|
| 240 |
+
src/common/ Pydantic schemas shared across stages
|
| 241 |
+
app/ React Native (Expo) app (Youngkyun)
|
| 242 |
+
src/routes/ expo-router (tabs, checkin, results, dev)
|
| 243 |
+
src/components/ characters, garden, scenes, layout
|
| 244 |
+
src/contexts/ Locale, Garden, DevMode
|
| 245 |
+
src/hooks/ Animation hooks (idle, blink, emotion, tap, wander)
|
| 246 |
+
notebooks/ KcELECTRA fine-tuning (Colab)
|
| 247 |
+
tests/ pytest (73 Python tests) + jest (71 JS tests)
|
| 248 |
+
docs/stage{1,2,3,4}/ Per-stage technical docs
|
| 249 |
+
docs/images/ README screenshots
|
| 250 |
+
config.yaml Global config (model paths, thresholds)
|
| 251 |
+
Dockerfile HF Spaces deployment (Python 3.12 + torch CPU + ffmpeg)
|
| 252 |
+
```
|
| 253 |
+
|
| 254 |
+
---
|
| 255 |
+
|
| 256 |
+
## Getting started
|
| 257 |
+
|
| 258 |
+
### 1. Clone
|
| 259 |
+
|
| 260 |
+
```bash
|
| 261 |
+
git clone https://github.com/boolooppang/UsTwo.git
|
| 262 |
+
cd UsTwo
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### 2. Backend (Python)
|
| 266 |
+
|
| 267 |
+
```bash
|
| 268 |
+
python -m venv venv && source venv/bin/activate
|
| 269 |
+
pip install -r requirements.txt
|
| 270 |
+
export HF_TOKEN=your_huggingface_token # required by pyannote
|
| 271 |
+
|
| 272 |
+
uvicorn src.stage4.main:app --reload --port 8000
|
| 273 |
+
# โ POST /api/upload, POST /api/analyze?call_id=X, GET /api/calls
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### 3. App (React Native / Expo)
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
cd app && npm install
|
| 280 |
+
npx expo start --dev-client
|
| 281 |
+
# Press 'i' for iOS simulator, or scan the QR code with a real device
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
To point the app at a different server, edit `API_BASE` in `app/src/api/client.ts` (it currently defaults to the HF Spaces URL).
|
| 285 |
+
|
| 286 |
+
### 4. Tests
|
| 287 |
+
|
| 288 |
+
```bash
|
| 289 |
+
python -m pytest tests/ -v # 73 Python tests
|
| 290 |
+
cd app && npx jest # 71 JS tests (144 total)
|
| 291 |
+
```
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
## Deployment โ HuggingFace Spaces (Docker)
|
| 296 |
+
|
| 297 |
+
The API server is deployed to HuggingFace Spaces as a Docker image, running the full pipeline (Stage 1 โ 2 โ 3).
|
| 298 |
+
|
| 299 |
+
- **Live URL:** `https://bbbakery-ustwo-api.hf.space`
|
| 300 |
+
- **Environment variables:** `HF_TOKEN`, `ANTHROPIC_API_KEY` (set in Spaces Settings).
|
| 301 |
+
- **Config files:** [`Dockerfile`](Dockerfile), [`railway.toml`](railway.toml), [`requirements-deploy.txt`](requirements-deploy.txt).
|
| 302 |
+
|
| 303 |
+
```bash
|
| 304 |
+
# Local Docker test
|
| 305 |
+
docker build -t ustwo .
|
| 306 |
+
docker run -p 7860:7860 -e HF_TOKEN=your_token ustwo
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
## Team & ownership
|
| 312 |
+
|
| 313 |
+
| Member | Role | Responsibility |
|
| 314 |
+
|--------|------|----------------|
|
| 315 |
+
| **Juhyun** | Product Owner / Researcher | Product design, UX research, empathic-accuracy literature review, emotion โ character mapping spec, evaluation plan, final paper |
|
| 316 |
+
| **Seungjae** | ML Engineer | Stage 1โ2 end to end โ diarization, ASR, audio emotion, text emotion (ko/en), RAVDESS/MELD evaluation, KcELECTRA fine-tuning |
|
| 317 |
+
| **Youngkyun** | App Engineer | Stage 3โ4 end to end โ character mapping implementation, garden logic, LLM recap, FastAPI + SQLite server, React Native app, HF Spaces Docker deployment |
|
| 318 |
+
|
| 319 |
+
---
|
| 320 |
+
|
| 321 |
+
## Success criteria
|
| 322 |
+
|
| 323 |
+
**MVP โ achieved:**
|
| 324 |
+
- โ
Upload a call recording โ character reaction + recap card within ~2 minutes.
|
| 325 |
+
- โ
Happy vs tense calls produce visibly different character reactions (9 pair states).
|
| 326 |
+
- โ
Cumulative positive calls grow the garden through 5 levels.
|
| 327 |
+
- โ
Bilingual pipeline (Korean + English) with automatic language detection.
|
| 328 |
+
- โ
MELD-based end-to-end test: 8/8 pipeline success, 7/7 exact emotion-label match.
|
| 329 |
+
|
| 330 |
+
---
|
| 331 |
+
|
| 332 |
+
## License
|
| 333 |
+
|
| 334 |
+
MIT
|
VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
2.0.0
|
config.yaml
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UsTwo Project Configuration
|
| 2 |
+
project:
|
| 3 |
+
name: "UsTwo"
|
| 4 |
+
version: "0.1.0"
|
| 5 |
+
|
| 6 |
+
paths:
|
| 7 |
+
data_dir: "data"
|
| 8 |
+
samples_dir: "data/samples"
|
| 9 |
+
models_dir: "data/models"
|
| 10 |
+
output_dir: "data"
|
| 11 |
+
|
| 12 |
+
# Stage 1: Speaker Diarization + ASR
|
| 13 |
+
stage1:
|
| 14 |
+
output_path: "data/stage1_output.json"
|
| 15 |
+
segments_dir: "data/segments"
|
| 16 |
+
|
| 17 |
+
preprocessing:
|
| 18 |
+
target_sample_rate: 16000
|
| 19 |
+
max_duration_sec: 300
|
| 20 |
+
min_duration_sec: 3
|
| 21 |
+
target_peak: 0.95 # peak normalization โ handles volume differences across devices
|
| 22 |
+
|
| 23 |
+
diarization:
|
| 24 |
+
model: "pyannote/speaker-diarization-3.1"
|
| 25 |
+
num_speakers: 2
|
| 26 |
+
merge_gap_sec: 0.15
|
| 27 |
+
|
| 28 |
+
asr:
|
| 29 |
+
model: "large-v3-turbo"
|
| 30 |
+
compute_type: "int8"
|
| 31 |
+
language: null # null = auto-detect
|
| 32 |
+
batch_size: 16
|
| 33 |
+
|
| 34 |
+
alignment:
|
| 35 |
+
enabled: true
|
| 36 |
+
|
| 37 |
+
language_id:
|
| 38 |
+
enabled: true
|
| 39 |
+
# SenseVoice disabled โ Whisper language + text heuristic ์ฌ์ฉ
|
| 40 |
+
# emotion2vec finetuning ์คํจ ์ SenseVoice ๊ฐ์ ํํธ ํ์ฉ ์์
|
| 41 |
+
# model: "FunAudioLLM/SenseVoiceSmall"
|
| 42 |
+
|
| 43 |
+
# Stage 2: Audio Emotion + Text Emotion
|
| 44 |
+
stage2:
|
| 45 |
+
input_path: "data/stage1_output.json"
|
| 46 |
+
output_path: "data/stage2_output.json"
|
| 47 |
+
audio_emotion:
|
| 48 |
+
model: "iic/emotion2vec_plus_base"
|
| 49 |
+
lora_onnx_path: "data/models/lora_emotion2vec_7class/model.onnx"
|
| 50 |
+
finetuned_checkpoint: null # legacy, use lora_onnx_path instead
|
| 51 |
+
text_emotion:
|
| 52 |
+
korean_model: "searle-j/kote_for_easygoing_people"
|
| 53 |
+
korean_lora_onnx_path: "data/models/lora_kcelectra_7class/model.onnx"
|
| 54 |
+
korean_lora_tokenizer: "data/models/lora_kcelectra_7class/best_model"
|
| 55 |
+
english_model: "j-hartmann/emotion-english-distilroberta-base"
|
| 56 |
+
fusion:
|
| 57 |
+
mode: "emotion_specific" # "emotion_specific" (per-class grid-search optimized) or "fixed" (60/40)
|
| 58 |
+
audio_weight: 0.6 # fallback for mode="fixed"
|
| 59 |
+
text_weight: 0.4
|
| 60 |
+
|
| 61 |
+
# Stage 3: Character Reaction + Garden + Recap
|
| 62 |
+
stage3:
|
| 63 |
+
input_path: "data/stage2_output.json"
|
| 64 |
+
output_path: "data/stage3_output.json"
|
| 65 |
+
recap:
|
| 66 |
+
llm_provider: "anthropic" # anthropic | openai
|
| 67 |
+
model: "claude-sonnet-4-20250514"
|
| 68 |
+
max_tokens: 500
|
| 69 |
+
garden:
|
| 70 |
+
growth_per_call: 5
|
| 71 |
+
max_level: 5
|
| 72 |
+
|
| 73 |
+
# Stage 4: FastAPI Server
|
| 74 |
+
stage4:
|
| 75 |
+
input_path: "data/stage3_output.json"
|
| 76 |
+
host: "0.0.0.0"
|
| 77 |
+
port: 8000
|
| 78 |
+
max_upload_size_mb: 50
|
| 79 |
+
allowed_extensions: [".wav", ".mp3", ".m4a", ".ogg"]
|
| 80 |
+
|
| 81 |
+
# API Keys (ํ๊ฒฝ ๋ณ์์์ ๋ก๋ โ .env ํ์ผ ์ฌ์ฉ)
|
| 82 |
+
api: {}
|
| 83 |
+
|
| 84 |
+
# Logging
|
| 85 |
+
logging:
|
| 86 |
+
level: "INFO"
|
| 87 |
+
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
data/meld_test/01_angry_fight.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a9e440869de56c29b94e85b12b6145181439011f1c2a24831ee58b17d268bda7
|
| 3 |
+
size 1098486
|
data/meld_test/02_happy_loving.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:194ce7561f2bb9efa54386938fb2fc0d87713590cbb7e728d7d3185c19f101cd
|
| 3 |
+
size 1400224
|
data/meld_test/03_sad_emotional.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:23c3e6458334dc0b24940144b9e202c82b7b86be6c0cdea982a76e7934206848
|
| 3 |
+
size 1584548
|
data/meld_test/04_surprise_shock.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2958c4ad34b23c793995b9357f80e5a3a89cf26d8675eff581402b17a2c01e9f
|
| 3 |
+
size 966734
|
data/meld_test/05_fear_anxiety.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3fdad57492923cc788a550f75a486950e61d9b851cf69a2e9a694da5c14bca8a
|
| 3 |
+
size 637008
|
data/meld_test/06_disgust_annoyance.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c5dd1600ef131cfe92ba93f79a5d82396164aade9902d8446ebef475480a2cb6
|
| 3 |
+
size 933964
|
data/meld_test/07_bittersweet.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:11c445fc5445a7e9c618b09deb351852ac616580ff7d59ac579a0be980a059f3
|
| 3 |
+
size 1384526
|
data/meld_test/08_calm_daily.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:05b2cc7fdac461bf9e8d6610388891552806a6a1350c55b4624f8209dcc7ca07
|
| 3 |
+
size 1294414
|
data/meld_test/09_opposite_emotions.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ce64da9783391134b20071c315ff794d9508b14c8c8d639a80e6d09267f1bb4f
|
| 3 |
+
size 796766
|
data/meld_test/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MELD English Test Sets
|
| 2 |
+
|
| 3 |
+
## Emotion Label Alignment
|
| 4 |
+
|
| 5 |
+
| UsTwo Pipeline (EN) | MELD Label | Match |
|
| 6 |
+
|---|---|---|
|
| 7 |
+
| neutral | neutral | โ
Exact |
|
| 8 |
+
| joy | joy | โ
Exact |
|
| 9 |
+
| sadness | sadness | โ
Exact |
|
| 10 |
+
| anger | anger | โ
Exact |
|
| 11 |
+
| surprise | surprise | โ
Exact |
|
| 12 |
+
| fear | fear | โ
Exact |
|
| 13 |
+
| disgust | disgust | โ
Exact |
|
| 14 |
+
|
| 15 |
+
**7/7 labels match exactly.** No mapping needed.
|
| 16 |
+
|
| 17 |
+
## Test Sets
|
| 18 |
+
|
| 19 |
+
| File | Scenario | Speakers | Primary Emotion | Duration | Utterances | Emotion Distribution |
|
| 20 |
+
|---|---|---|---|---|---|---|
|
| 21 |
+
| 01_angry_fight | Couple in a heated argument | Ross, Rachel | anger | 36.9s | 11 utts | anger:7 neutral:2 sadness:1 disgust:1 |
|
| 22 |
+
| 02_happy_loving | Couple being affectionate and playful | Chandler, Monica | joy | 43.8s | 16 utts | joy:6 surprise:5 anger:3 neutral:1 sadness:1 |
|
| 23 |
+
| 03_sad_emotional | Emotional confession โ "you still love me?" | Ross, Rachel | sadness | 55.4s | 17 utts | neutral:7 sadness:4 anger:3 surprise:3 |
|
| 24 |
+
| 04_surprise_shock | Drunk voicemail surprise scene | Ross, Rachel | surprise | 30.2s | 8 utts | surprise:5 neutral:2 sadness:1 |
|
| 25 |
+
| 05_fear_anxiety | Anxious and worried conversation | Chandler, Rachel | fear | 19.9s | 12 utts | fear:5 neutral:4 surprise:2 sadness:1 |
|
| 26 |
+
| 06_disgust_annoyance | Annoyed and frustrated bickering | Joey, Rachel | anger | 29.2s | 11 utts | anger:5 neutral:2 sadness:1 surprise:1 fear:1 joy:1 |
|
| 27 |
+
| 07_bittersweet | Saying goodbye with conflicting feelings | Ross, Rachel | sadness | 43.3s | 14 utts | sadness:6 surprise:3 anger:3 fear:1 neutral:1 |
|
| 28 |
+
| 08_calm_daily | Casual everyday chitchat (baseline) | Joey, Monica | neutral | 40.4s | 15 utts | neutral:13 joy:2 |
|
| 29 |
+
| 09_opposite_emotions | Tense speaker + calm listener โ triggers `listening` pair animation | 2 spk | surprise | 24.9s | 11 segs | surprise:5 neutral:3 anger:1 fear:1 joy:1 (pipeline fused) |
|
| 30 |
+
|
| 31 |
+
## Notes
|
| 32 |
+
- All dialogues are 2-speaker (male + female) conversations
|
| 33 |
+
- 03: Ross+Rachel only, utt15 removed (timestamp overlap with utt14)
|
| 34 |
+
- 04: starts from "Rach, I got a message from you", utt3/utt8 removed (timestamp overlaps)
|
| 35 |
+
- 06: Joey+Rachel only, utt16 removed (addresses Ross)
|
| 36 |
+
- 07: utt11 removed (timestamp overlap with utt10)
|
| 37 |
+
- 09: **opposite-emotion demo scene (updated 2026-04-22)** โ WAV replaced by user. E2E pipeline output: speaker_0 dominant=surprise (max 0.68, tense via anger residual), speaker_1 dominant=neutral (max 0.54, calm). `(tense, calm)` โ **pair_state=`listening`** triggers (sparkles effect, speaker_1 = giver/listener). Recap captures "one expressing worry through sharp words, the other sharing tender vision." Per-utterance ground-truth labels not available post-update; distribution numbers derived from pipeline fused output.
|
| 38 |
+
|
| 39 |
+
## Source
|
| 40 |
+
- Dataset: MELD (Multimodal EmotionLines Dataset)
|
| 41 |
+
- Source: Friends TV series
|
| 42 |
+
- Paper: Poria et al., ACL 2019
|
| 43 |
+
- Each WAV is a full dialogue concatenated from per-utterance MP4 clips
|
| 44 |
+
- Audio: 16kHz mono PCM (matches pipeline input format)
|
| 45 |
+
|
| 46 |
+
## Usage
|
| 47 |
+
```bash
|
| 48 |
+
# Run pipeline on a single test set
|
| 49 |
+
python scripts/run_pipeline.py data/meld_test/01_angry_fight.wav
|
| 50 |
+
|
| 51 |
+
# Evaluate all test sets
|
| 52 |
+
python scripts/evaluate_meld_test.py
|
| 53 |
+
```
|
data/meld_test/ground_truth.json
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"01_angry_fight": {
|
| 3 |
+
"description": "Ross-Rachel breakup fight โ anger dominant (S3E15)",
|
| 4 |
+
"scenario": "Couple in a heated argument",
|
| 5 |
+
"primary_emotion": "anger",
|
| 6 |
+
"source": "MELD Friends S3E15 Dialogue 51 (utt0 removed, overlap fix)",
|
| 7 |
+
"duration_sec": 34.3,
|
| 8 |
+
"emotion_distribution": {
|
| 9 |
+
"sadness": 1,
|
| 10 |
+
"neutral": 1,
|
| 11 |
+
"disgust": 1,
|
| 12 |
+
"anger": 7
|
| 13 |
+
},
|
| 14 |
+
"total_utterances": 10,
|
| 15 |
+
"utterances": [
|
| 16 |
+
{
|
| 17 |
+
"speaker": "Rachel",
|
| 18 |
+
"emotion": "sadness",
|
| 19 |
+
"sentiment": "negative",
|
| 20 |
+
"text": "Hi. Look um, about what happened earlier..."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"speaker": "Ross",
|
| 24 |
+
"emotion": "neutral",
|
| 25 |
+
"sentiment": "neutral",
|
| 26 |
+
"text": "No, hey, well, I-I completely understand. You were, you were stressed."
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"speaker": "Rachel",
|
| 30 |
+
"emotion": "disgust",
|
| 31 |
+
"sentiment": "negative",
|
| 32 |
+
"text": "I was gonna give you a chance to apologise to me."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"speaker": "Ross",
|
| 36 |
+
"emotion": "anger",
|
| 37 |
+
"sentiment": "negative",
|
| 38 |
+
"text": "For what? For letting you throw me out of your office?"
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"speaker": "Rachel",
|
| 42 |
+
"emotion": "anger",
|
| 43 |
+
"sentiment": "negative",
|
| 44 |
+
"text": "You had no"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"speaker": "Ross",
|
| 48 |
+
"emotion": "anger",
|
| 49 |
+
"sentiment": "negative",
|
| 50 |
+
"text": "Yeah, well excuse me for wanting to be with my girlfriend on our anniversary, boy what an ass am I."
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"speaker": "Rachel",
|
| 54 |
+
"emotion": "anger",
|
| 55 |
+
"sentiment": "negative",
|
| 56 |
+
"text": "But I told you, I didnโt have the time!"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"speaker": "Ross",
|
| 60 |
+
"emotion": "anger",
|
| 61 |
+
"sentiment": "negative",
|
| 62 |
+
"text": "Yeah, well you never have the time. I mean, I donโt feel like I even have a girlfriend anymore, Rachel."
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"speaker": "Rachel",
|
| 66 |
+
"emotion": "anger",
|
| 67 |
+
"sentiment": "negative",
|
| 68 |
+
"text": "Wh, Ross what do you want from me?"
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"speaker": "Rachel",
|
| 72 |
+
"emotion": "anger",
|
| 73 |
+
"sentiment": "negative",
|
| 74 |
+
"text": "You want me, you want me to quit my job so you can feel like you have a girlfriend?"
|
| 75 |
+
}
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
"02_happy_loving": {
|
| 79 |
+
"description": "Monica-Chandler sweet moment โ joy dominant (S5E14)",
|
| 80 |
+
"scenario": "Couple being affectionate and playful",
|
| 81 |
+
"primary_emotion": "joy",
|
| 82 |
+
"source": "MELD Friends S5E14 Dialogue 1026",
|
| 83 |
+
"duration_sec": 43.8,
|
| 84 |
+
"emotion_distribution": {
|
| 85 |
+
"joy": 6,
|
| 86 |
+
"neutral": 1,
|
| 87 |
+
"surprise": 5,
|
| 88 |
+
"anger": 3,
|
| 89 |
+
"sadness": 1
|
| 90 |
+
},
|
| 91 |
+
"total_utterances": 16,
|
| 92 |
+
"utterances": [
|
| 93 |
+
{
|
| 94 |
+
"speaker": "Monica",
|
| 95 |
+
"emotion": "joy",
|
| 96 |
+
"sentiment": "positive",
|
| 97 |
+
"text": "You are so cute! How did you get to be so cute?"
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"speaker": "Chandler",
|
| 101 |
+
"emotion": "joy",
|
| 102 |
+
"sentiment": "positive",
|
| 103 |
+
"text": "Well, my Grandfather was Swedish and my Grandmother was actually a tiny little bunny."
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"speaker": "Monica",
|
| 107 |
+
"emotion": "joy",
|
| 108 |
+
"sentiment": "positive",
|
| 109 |
+
"text": "Okay, now you're even cuter!!"
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"speaker": "Chandler",
|
| 113 |
+
"emotion": "neutral",
|
| 114 |
+
"sentiment": "neutral",
|
| 115 |
+
"text": "Y'know that is a popular opinion today I must say."
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"speaker": "Monica",
|
| 119 |
+
"emotion": "surprise",
|
| 120 |
+
"sentiment": "negative",
|
| 121 |
+
"text": "What?"
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"speaker": "Chandler",
|
| 125 |
+
"emotion": "surprise",
|
| 126 |
+
"sentiment": "negative",
|
| 127 |
+
"text": "The weirdest thing happened at the coffee house, I think, I think Phoebe was hitting on me."
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"speaker": "Monica",
|
| 131 |
+
"emotion": "surprise",
|
| 132 |
+
"sentiment": "negative",
|
| 133 |
+
"text": "What are you talking about?"
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"speaker": "Chandler",
|
| 137 |
+
"emotion": "joy",
|
| 138 |
+
"sentiment": "positive",
|
| 139 |
+
"text": "I'm telling you I think Phoebe thinks I'm foxy."
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"speaker": "Monica",
|
| 143 |
+
"emotion": "joy",
|
| 144 |
+
"sentiment": "positive",
|
| 145 |
+
"text": "That's not possible!"
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"speaker": "Chandler",
|
| 149 |
+
"emotion": "surprise",
|
| 150 |
+
"sentiment": "positive",
|
| 151 |
+
"text": "Ow!"
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"speaker": "Monica",
|
| 155 |
+
"emotion": "joy",
|
| 156 |
+
"sentiment": "positive",
|
| 157 |
+
"text": "I'm sorry it's just, Phoebe just always thought you were, you were charming in a, in a sexless kind of way."
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"speaker": "Chandler",
|
| 161 |
+
"emotion": "anger",
|
| 162 |
+
"sentiment": "negative",
|
| 163 |
+
"text": "Oh, y'know I-I can't hear that enough."
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"speaker": "Monica",
|
| 167 |
+
"emotion": "sadness",
|
| 168 |
+
"sentiment": "negative",
|
| 169 |
+
"text": "I'm sorry, I think that you just misunderstood her."
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"speaker": "Chandler",
|
| 173 |
+
"emotion": "anger",
|
| 174 |
+
"sentiment": "negative",
|
| 175 |
+
"text": "No, I didn't misunderstand, okay? She was all over me! She touched my bicep for crying out loud!"
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"speaker": "Monica",
|
| 179 |
+
"emotion": "surprise",
|
| 180 |
+
"sentiment": "negative",
|
| 181 |
+
"text": "This bicep?"
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"speaker": "Chandler",
|
| 185 |
+
"emotion": "anger",
|
| 186 |
+
"sentiment": "negative",
|
| 187 |
+
"text": "Well it's not flexed right now!"
|
| 188 |
+
}
|
| 189 |
+
]
|
| 190 |
+
},
|
| 191 |
+
"03_sad_emotional": {
|
| 192 |
+
"description": "Ross-Rachel emotional confession โ sadness dominant (S3E25)",
|
| 193 |
+
"scenario": "Emotional conversation with sadness and regret",
|
| 194 |
+
"primary_emotion": "sadness",
|
| 195 |
+
"source": "MELD Friends S3E25 Dialogue 312 (Ross+Rachel only, utt15/19 removed)",
|
| 196 |
+
"duration_sec": 49.5,
|
| 197 |
+
"emotion_distribution": {
|
| 198 |
+
"neutral": 6,
|
| 199 |
+
"anger": 3,
|
| 200 |
+
"sadness": 4,
|
| 201 |
+
"surprise": 3
|
| 202 |
+
},
|
| 203 |
+
"total_utterances": 16,
|
| 204 |
+
"utterances": [
|
| 205 |
+
{
|
| 206 |
+
"speaker": "Ross",
|
| 207 |
+
"emotion": "neutral",
|
| 208 |
+
"sentiment": "neutral",
|
| 209 |
+
"text": "You donโt know?! Rach, you balded my girlfriend!"
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"speaker": "Rachel",
|
| 213 |
+
"emotion": "anger",
|
| 214 |
+
"sentiment": "negative",
|
| 215 |
+
"text": "All right! Ross, do you think itโs easy for me to see you with somebody else?"
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"speaker": "Ross",
|
| 219 |
+
"emotion": "anger",
|
| 220 |
+
"sentiment": "negative",
|
| 221 |
+
"text": "Y'know, hey! Youโre the one who ended it, remember?"
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"speaker": "Rachel",
|
| 225 |
+
"emotion": "neutral",
|
| 226 |
+
"sentiment": "neutral",
|
| 227 |
+
"text": "Yeah, because I was"
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
"speaker": "Ross",
|
| 231 |
+
"emotion": "sadness",
|
| 232 |
+
"sentiment": "negative",
|
| 233 |
+
"text": "You still love me?"
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"speaker": "Rachel",
|
| 237 |
+
"emotion": "sadness",
|
| 238 |
+
"sentiment": "negative",
|
| 239 |
+
"text": "Noo."
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"speaker": "Ross",
|
| 243 |
+
"emotion": "neutral",
|
| 244 |
+
"sentiment": "neutral",
|
| 245 |
+
"text": "You still love me."
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"speaker": "Rachel",
|
| 249 |
+
"emotion": "surprise",
|
| 250 |
+
"sentiment": "positive",
|
| 251 |
+
"text": "Oh, y-yeah, so, you-you love me!"
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"speaker": "Ross",
|
| 255 |
+
"emotion": "surprise",
|
| 256 |
+
"sentiment": "positive",
|
| 257 |
+
"text": "Noo, nnnnn. What does this mean? What do you, I mean do you wanna, get back together?"
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"speaker": "Rachel",
|
| 261 |
+
"emotion": "anger",
|
| 262 |
+
"sentiment": "negative",
|
| 263 |
+
"text": "Noo!"
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"speaker": "Rachel",
|
| 267 |
+
"emotion": "surprise",
|
| 268 |
+
"sentiment": "positive",
|
| 269 |
+
"text": "Maybe!"
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"speaker": "Rachel",
|
| 273 |
+
"emotion": "neutral",
|
| 274 |
+
"sentiment": "neutral",
|
| 275 |
+
"text": "I, I donโt know."
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"speaker": "Rachel",
|
| 279 |
+
"emotion": "sadness",
|
| 280 |
+
"sentiment": "negative",
|
| 281 |
+
"text": "Ross, I still canโt forgive you for what you did, I canโt, I just, but sometimes when Iโm with you I just, I feel so..."
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"speaker": "Ross",
|
| 285 |
+
"emotion": "sadness",
|
| 286 |
+
"sentiment": "negative",
|
| 287 |
+
"text": "What?!"
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"speaker": "Rachel",
|
| 291 |
+
"emotion": "neutral",
|
| 292 |
+
"sentiment": "neutral",
|
| 293 |
+
"text": "I just, I feel, I-I just..."
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"speaker": "Rachel",
|
| 297 |
+
"emotion": "neutral",
|
| 298 |
+
"sentiment": "neutral",
|
| 299 |
+
"text": "I feel..."
|
| 300 |
+
}
|
| 301 |
+
]
|
| 302 |
+
},
|
| 303 |
+
"04_surprise_shock": {
|
| 304 |
+
"description": "Ross-Rachel drunk voicemail surprise (S4E21)",
|
| 305 |
+
"scenario": "Unexpected news and surprising revelations between couple",
|
| 306 |
+
"primary_emotion": "surprise",
|
| 307 |
+
"source": "MELD Friends S4E21 Dialogue 848 (from utt2, overlaps removed)",
|
| 308 |
+
"duration_sec": 30.2,
|
| 309 |
+
"emotion_distribution": {
|
| 310 |
+
"neutral": 2,
|
| 311 |
+
"sadness": 1,
|
| 312 |
+
"surprise": 5
|
| 313 |
+
},
|
| 314 |
+
"total_utterances": 8,
|
| 315 |
+
"utterances": [
|
| 316 |
+
{
|
| 317 |
+
"speaker": "Ross",
|
| 318 |
+
"emotion": "neutral",
|
| 319 |
+
"sentiment": "neutral",
|
| 320 |
+
"text": "Rach, I got a message from you."
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"speaker": "Rachel",
|
| 324 |
+
"emotion": "sadness",
|
| 325 |
+
"sentiment": "negative",
|
| 326 |
+
"text": "Oh my God Ross, no, hang up the phone, give me the phone Ross, give me the phone, give me the phone, give me the."
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"speaker": "Ross",
|
| 330 |
+
"emotion": "surprise",
|
| 331 |
+
"sentiment": "negative",
|
| 332 |
+
"text": "You're over me?"
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"speaker": "Rachel",
|
| 336 |
+
"emotion": "surprise",
|
| 337 |
+
"sentiment": "negative",
|
| 338 |
+
"text": "Ohhhhhhhh God."
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"speaker": "Ross",
|
| 342 |
+
"emotion": "surprise",
|
| 343 |
+
"sentiment": "negative",
|
| 344 |
+
"text": "Wha... you're uh, you're, you're over me?"
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"speaker": "Ross",
|
| 348 |
+
"emotion": "surprise",
|
| 349 |
+
"sentiment": "negative",
|
| 350 |
+
"text": "When, when were you... under me?"
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"speaker": "Rachel",
|
| 354 |
+
"emotion": "neutral",
|
| 355 |
+
"sentiment": "neutral",
|
| 356 |
+
"text": "Well, basically, lately, I've uh, I've uh, sort of had feelings for you."
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"speaker": "Ross",
|
| 360 |
+
"emotion": "surprise",
|
| 361 |
+
"sentiment": "negative",
|
| 362 |
+
"text": "OK, I need to lie down."
|
| 363 |
+
}
|
| 364 |
+
]
|
| 365 |
+
},
|
| 366 |
+
"05_fear_anxiety": {
|
| 367 |
+
"description": "Chandler-Rachel anxious situation โ fear dominant (S7E11)",
|
| 368 |
+
"scenario": "Anxious and worried conversation between two people",
|
| 369 |
+
"primary_emotion": "fear",
|
| 370 |
+
"source": "MELD Friends S7E11 Dialogue 989",
|
| 371 |
+
"duration_sec": 19.9,
|
| 372 |
+
"emotion_distribution": {
|
| 373 |
+
"surprise": 2,
|
| 374 |
+
"neutral": 4,
|
| 375 |
+
"sadness": 1,
|
| 376 |
+
"fear": 5
|
| 377 |
+
},
|
| 378 |
+
"total_utterances": 12,
|
| 379 |
+
"utterances": [
|
| 380 |
+
{
|
| 381 |
+
"speaker": "Rachel",
|
| 382 |
+
"emotion": "surprise",
|
| 383 |
+
"sentiment": "positive",
|
| 384 |
+
"text": "Its still there!"
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"speaker": "Chandler",
|
| 388 |
+
"emotion": "neutral",
|
| 389 |
+
"sentiment": "neutral",
|
| 390 |
+
"text": "Mrs. Braverman must be out."
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"speaker": "Rachel",
|
| 394 |
+
"emotion": "sadness",
|
| 395 |
+
"sentiment": "negative",
|
| 396 |
+
"text": "She could be out of town. Maybe sheโll be gone for months."
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"speaker": "Chandler",
|
| 400 |
+
"emotion": "fear",
|
| 401 |
+
"sentiment": "negative",
|
| 402 |
+
"text": "By then, the cheesecake may have gone bad. We donโt want her to come back to bad cheesecake."
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"speaker": "Rachel",
|
| 406 |
+
"emotion": "fear",
|
| 407 |
+
"sentiment": "negative",
|
| 408 |
+
"text": "No that could kill her."
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"speaker": "Chandler",
|
| 412 |
+
"emotion": "neutral",
|
| 413 |
+
"sentiment": "neutral",
|
| 414 |
+
"text": "Well, we donโt want that."
|
| 415 |
+
},
|
| 416 |
+
{
|
| 417 |
+
"speaker": "Rachel",
|
| 418 |
+
"emotion": "neutral",
|
| 419 |
+
"sentiment": "neutral",
|
| 420 |
+
"text": "No, so weโre protecting her."
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"speaker": "Chandler",
|
| 424 |
+
"emotion": "neutral",
|
| 425 |
+
"sentiment": "neutral",
|
| 426 |
+
"text": "But we should take it."
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"speaker": "Rachel",
|
| 430 |
+
"emotion": "fear",
|
| 431 |
+
"sentiment": "negative",
|
| 432 |
+
"text": "But we should move quick."
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"speaker": "Chandler",
|
| 436 |
+
"emotion": "surprise",
|
| 437 |
+
"sentiment": "negative",
|
| 438 |
+
"text": "Why?"
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"speaker": "Rachel",
|
| 442 |
+
"emotion": "fear",
|
| 443 |
+
"sentiment": "negative",
|
| 444 |
+
"text": "Because I think I just heard her moving around in there."
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"speaker": "Chandler",
|
| 448 |
+
"emotion": "fear",
|
| 449 |
+
"sentiment": "negative",
|
| 450 |
+
"text": "Go! Go! Go! Go! Go! Go! Go! Go! Go! Go!"
|
| 451 |
+
}
|
| 452 |
+
]
|
| 453 |
+
},
|
| 454 |
+
"06_disgust_annoyance": {
|
| 455 |
+
"description": "Joey-Rachel annoyance/bickering scene (S6E9)",
|
| 456 |
+
"scenario": "Annoyed and frustrated reactions between couple",
|
| 457 |
+
"primary_emotion": "anger",
|
| 458 |
+
"source": "MELD Friends S6E9 Dialogue 1025 (Joey+Rachel only, utt16 removed)",
|
| 459 |
+
"duration_sec": 29.2,
|
| 460 |
+
"emotion_distribution": {
|
| 461 |
+
"sadness": 1,
|
| 462 |
+
"surprise": 1,
|
| 463 |
+
"anger": 5,
|
| 464 |
+
"neutral": 2,
|
| 465 |
+
"fear": 1,
|
| 466 |
+
"joy": 1
|
| 467 |
+
},
|
| 468 |
+
"total_utterances": 11,
|
| 469 |
+
"utterances": [
|
| 470 |
+
{
|
| 471 |
+
"speaker": "Joey",
|
| 472 |
+
"emotion": "sadness",
|
| 473 |
+
"sentiment": "negative",
|
| 474 |
+
"text": "Will you hurry up?"
|
| 475 |
+
},
|
| 476 |
+
{
|
| 477 |
+
"speaker": "Joey",
|
| 478 |
+
"emotion": "surprise",
|
| 479 |
+
"sentiment": "negative",
|
| 480 |
+
"text": "Did you not hear me before when I told you that all of Janineโs friends are dancers?!"
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"speaker": "Joey",
|
| 484 |
+
"emotion": "anger",
|
| 485 |
+
"sentiment": "negative",
|
| 486 |
+
"text": "And that theyโre going to be drinking alot!"
|
| 487 |
+
},
|
| 488 |
+
{
|
| 489 |
+
"speaker": "Rachel",
|
| 490 |
+
"emotion": "neutral",
|
| 491 |
+
"sentiment": "neutral",
|
| 492 |
+
"text": "No, I did, but tell me again, because itโs so romantic."
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"speaker": "Joey",
|
| 496 |
+
"emotion": "anger",
|
| 497 |
+
"sentiment": "negative",
|
| 498 |
+
"text": "Well youโre whippinโ so slow! Canโt you do it any faster?"
|
| 499 |
+
},
|
| 500 |
+
{
|
| 501 |
+
"speaker": "Rachel",
|
| 502 |
+
"emotion": "anger",
|
| 503 |
+
"sentiment": "negative",
|
| 504 |
+
"text": "Joey!"
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"speaker": "Rachel",
|
| 508 |
+
"emotion": "anger",
|
| 509 |
+
"sentiment": "negative",
|
| 510 |
+
"text": "Come on!"
|
| 511 |
+
},
|
| 512 |
+
{
|
| 513 |
+
"speaker": "Rachel",
|
| 514 |
+
"emotion": "anger",
|
| 515 |
+
"sentiment": "negative",
|
| 516 |
+
"text": "I donโt wanna make any mistakes, alright?"
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"speaker": "Rachel",
|
| 520 |
+
"emotion": "fear",
|
| 521 |
+
"sentiment": "negative",
|
| 522 |
+
"text": "This is the only dessert and if I screw it up everybody's gonna be like โOh, remember that Thanksgiving when Rachel screwed up the trifle?โ"
|
| 523 |
+
},
|
| 524 |
+
{
|
| 525 |
+
"speaker": "Rachel",
|
| 526 |
+
"emotion": "neutral",
|
| 527 |
+
"sentiment": "neutral",
|
| 528 |
+
"text": "So why donโt you just let me worry about making the trifle and you just worry about eating it, alright?"
|
| 529 |
+
},
|
| 530 |
+
{
|
| 531 |
+
"speaker": "Joey",
|
| 532 |
+
"emotion": "joy",
|
| 533 |
+
"sentiment": "positive",
|
| 534 |
+
"text": "Oh I am!"
|
| 535 |
+
}
|
| 536 |
+
]
|
| 537 |
+
},
|
| 538 |
+
"07_bittersweet": {
|
| 539 |
+
"description": "Ross-Rachel bittersweet farewell โ sadness+surprise (S5E5), overlap fixed",
|
| 540 |
+
"scenario": "Mixed emotions: saying goodbye with conflicting feelings",
|
| 541 |
+
"primary_emotion": "sadness",
|
| 542 |
+
"source": "MELD Friends S5E5 Dialogue 676 (utt11 overlap removed)",
|
| 543 |
+
"duration_sec": 43.3,
|
| 544 |
+
"emotion_distribution": {
|
| 545 |
+
"sadness": 6,
|
| 546 |
+
"surprise": 3,
|
| 547 |
+
"fear": 1,
|
| 548 |
+
"anger": 3,
|
| 549 |
+
"neutral": 1
|
| 550 |
+
},
|
| 551 |
+
"total_utterances": 14,
|
| 552 |
+
"utterances": [
|
| 553 |
+
{
|
| 554 |
+
"speaker": "Ross",
|
| 555 |
+
"emotion": "sadness",
|
| 556 |
+
"sentiment": "negative",
|
| 557 |
+
"text": "is for me not to see you anymore."
|
| 558 |
+
},
|
| 559 |
+
{
|
| 560 |
+
"speaker": "Rachel",
|
| 561 |
+
"emotion": "surprise",
|
| 562 |
+
"sentiment": "positive",
|
| 563 |
+
"text": "That's crazy!"
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
"speaker": "Rachel",
|
| 567 |
+
"emotion": "surprise",
|
| 568 |
+
"sentiment": "positive",
|
| 569 |
+
"text": "You can't do that!"
|
| 570 |
+
},
|
| 571 |
+
{
|
| 572 |
+
"speaker": "Rachel",
|
| 573 |
+
"emotion": "sadness",
|
| 574 |
+
"sentiment": "negative",
|
| 575 |
+
"text": "What are you going to tell her?"
|
| 576 |
+
},
|
| 577 |
+
{
|
| 578 |
+
"speaker": "Rachel",
|
| 579 |
+
"emotion": "fear",
|
| 580 |
+
"sentiment": "negative",
|
| 581 |
+
"text": "Oh God."
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
"speaker": "Rachel",
|
| 585 |
+
"emotion": "sadness",
|
| 586 |
+
"sentiment": "negative",
|
| 587 |
+
"text": "Ohh, you already agreed to this, haven't you?"
|
| 588 |
+
},
|
| 589 |
+
{
|
| 590 |
+
"speaker": "Ross",
|
| 591 |
+
"emotion": "sadness",
|
| 592 |
+
"sentiment": "negative",
|
| 593 |
+
"text": "It's awful I know, I mean, I feel terrible but I have to do this if I want my marriage to work."
|
| 594 |
+
},
|
| 595 |
+
{
|
| 596 |
+
"speaker": "Ross",
|
| 597 |
+
"emotion": "sadness",
|
| 598 |
+
"sentiment": "negative",
|
| 599 |
+
"text": "And I do, I have to make"
|
| 600 |
+
},
|
| 601 |
+
{
|
| 602 |
+
"speaker": "Rachel",
|
| 603 |
+
"emotion": "surprise",
|
| 604 |
+
"sentiment": "positive",
|
| 605 |
+
"text": "Ohh! Lucky me! Oh my God! That"
|
| 606 |
+
},
|
| 607 |
+
{
|
| 608 |
+
"speaker": "Ross",
|
| 609 |
+
"emotion": "sadness",
|
| 610 |
+
"sentiment": "negative",
|
| 611 |
+
"text": "You have no idea what a nightmare this has been. This is so hard."
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"speaker": "Rachel",
|
| 615 |
+
"emotion": "anger",
|
| 616 |
+
"sentiment": "negative",
|
| 617 |
+
"text": "Oh yeah, really? Is it Ross? Yeah? Okay, well let me make this a just a little bit easier for you."
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"speaker": "Rachel",
|
| 621 |
+
"emotion": "anger",
|
| 622 |
+
"sentiment": "negative",
|
| 623 |
+
"text": "Storming out!"
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
"speaker": "Ross",
|
| 627 |
+
"emotion": "neutral",
|
| 628 |
+
"sentiment": "neutral",
|
| 629 |
+
"text": "Rachel, this is your apartment."
|
| 630 |
+
},
|
| 631 |
+
{
|
| 632 |
+
"speaker": "Rachel",
|
| 633 |
+
"emotion": "anger",
|
| 634 |
+
"sentiment": "negative",
|
| 635 |
+
"text": "Yeah, well that's how mad I am!!"
|
| 636 |
+
}
|
| 637 |
+
]
|
| 638 |
+
},
|
| 639 |
+
"08_calm_daily": {
|
| 640 |
+
"description": "Joey-Monica casual conversation โ neutral dominant (S7E19)",
|
| 641 |
+
"scenario": "Normal everyday chitchat between friends (baseline)",
|
| 642 |
+
"primary_emotion": "neutral",
|
| 643 |
+
"source": "MELD Friends S7E19 Dialogue 8 (dev)",
|
| 644 |
+
"duration_sec": 40.4,
|
| 645 |
+
"emotion_distribution": {
|
| 646 |
+
"neutral": 13,
|
| 647 |
+
"joy": 2
|
| 648 |
+
},
|
| 649 |
+
"total_utterances": 15,
|
| 650 |
+
"utterances": [
|
| 651 |
+
{
|
| 652 |
+
"speaker": "Monica",
|
| 653 |
+
"emotion": "neutral",
|
| 654 |
+
"sentiment": "neutral",
|
| 655 |
+
"text": "Hey! What did you decide to do about the movie?"
|
| 656 |
+
},
|
| 657 |
+
{
|
| 658 |
+
"speaker": "Joey",
|
| 659 |
+
"emotion": "neutral",
|
| 660 |
+
"sentiment": "neutral",
|
| 661 |
+
"text": "I donโt know!"
|
| 662 |
+
},
|
| 663 |
+
{
|
| 664 |
+
"speaker": "Joey",
|
| 665 |
+
"emotion": "neutral",
|
| 666 |
+
"sentiment": "neutral",
|
| 667 |
+
"text": "Itโs not like itโs porn!"
|
| 668 |
+
},
|
| 669 |
+
{
|
| 670 |
+
"speaker": "Joey",
|
| 671 |
+
"emotion": "neutral",
|
| 672 |
+
"sentiment": "neutral",
|
| 673 |
+
"text": "This is a serious, legitimate movie."
|
| 674 |
+
},
|
| 675 |
+
{
|
| 676 |
+
"speaker": "Joey",
|
| 677 |
+
"emotion": "neutral",
|
| 678 |
+
"sentiment": "neutral",
|
| 679 |
+
"text": "And the nudity is really important to the story."
|
| 680 |
+
},
|
| 681 |
+
{
|
| 682 |
+
"speaker": "Monica",
|
| 683 |
+
"emotion": "neutral",
|
| 684 |
+
"sentiment": "neutral",
|
| 685 |
+
"text": "Thatโs what you say about porn."
|
| 686 |
+
},
|
| 687 |
+
{
|
| 688 |
+
"speaker": "Joey",
|
| 689 |
+
"emotion": "neutral",
|
| 690 |
+
"sentiment": "neutral",
|
| 691 |
+
"text": "Youโre right. Maybe I shouldnโt even go on the call back."
|
| 692 |
+
},
|
| 693 |
+
{
|
| 694 |
+
"speaker": "Monica",
|
| 695 |
+
"emotion": "joy",
|
| 696 |
+
"sentiment": "positive",
|
| 697 |
+
"text": "No! No you should! A lot of major actors do nude scenes! I mean, the chance to star in a movie? Come on!"
|
| 698 |
+
},
|
| 699 |
+
{
|
| 700 |
+
"speaker": "Joey",
|
| 701 |
+
"emotion": "neutral",
|
| 702 |
+
"sentiment": "neutral",
|
| 703 |
+
"text": "Well thatโs true."
|
| 704 |
+
},
|
| 705 |
+
{
|
| 706 |
+
"speaker": "Joey",
|
| 707 |
+
"emotion": "neutral",
|
| 708 |
+
"sentiment": "neutral",
|
| 709 |
+
"text": "And I am only naked in one scene."
|
| 710 |
+
},
|
| 711 |
+
{
|
| 712 |
+
"speaker": "Joey",
|
| 713 |
+
"emotion": "neutral",
|
| 714 |
+
"sentiment": "neutral",
|
| 715 |
+
"text": "Plus it sounds really great."
|
| 716 |
+
},
|
| 717 |
+
{
|
| 718 |
+
"speaker": "Joey",
|
| 719 |
+
"emotion": "neutral",
|
| 720 |
+
"sentiment": "neutral",
|
| 721 |
+
"text": "My characterโs catholic and he falls in love with this Jewish girl."
|
| 722 |
+
},
|
| 723 |
+
{
|
| 724 |
+
"speaker": "Joey",
|
| 725 |
+
"emotion": "neutral",
|
| 726 |
+
"sentiment": "neutral",
|
| 727 |
+
"text": "Who run away together and they get caught in this big rainstorm."
|
| 728 |
+
},
|
| 729 |
+
{
|
| 730 |
+
"speaker": "Joey",
|
| 731 |
+
"emotion": "neutral",
|
| 732 |
+
"sentiment": "neutral",
|
| 733 |
+
"text": "So we go into this barn and undress each other and hold each other."
|
| 734 |
+
},
|
| 735 |
+
{
|
| 736 |
+
"speaker": "Joey",
|
| 737 |
+
"emotion": "joy",
|
| 738 |
+
"sentiment": "positive",
|
| 739 |
+
"text": "Itโs really sweet and-and tender."
|
| 740 |
+
}
|
| 741 |
+
]
|
| 742 |
+
},
|
| 743 |
+
"09_opposite_emotions": {
|
| 744 |
+
"description": "Updated clip โ pair_state 'listening' triggers (tense speaker + calm listener)",
|
| 745 |
+
"scenario": "One speaker worked-up/tense + one calm listener โ triggers listening pair animation (sparkles effect)",
|
| 746 |
+
"primary_emotion": "surprise",
|
| 747 |
+
"source": "MELD Friends clip (user-updated 2026-04-22, duration 24.9s)",
|
| 748 |
+
"duration_sec": 24.9,
|
| 749 |
+
"pair_state_observed": "listening",
|
| 750 |
+
"emotion_distribution": {
|
| 751 |
+
"surprise": 5,
|
| 752 |
+
"anger": 1,
|
| 753 |
+
"neutral": 3,
|
| 754 |
+
"fear": 1,
|
| 755 |
+
"joy": 1
|
| 756 |
+
},
|
| 757 |
+
"per_speaker_fused": {
|
| 758 |
+
"speaker_0": {
|
| 759 |
+
"surprise": 4,
|
| 760 |
+
"anger": 1
|
| 761 |
+
},
|
| 762 |
+
"speaker_1": {
|
| 763 |
+
"surprise": 1,
|
| 764 |
+
"neutral": 3,
|
| 765 |
+
"fear": 1,
|
| 766 |
+
"joy": 1
|
| 767 |
+
}
|
| 768 |
+
},
|
| 769 |
+
"total_segments": 11,
|
| 770 |
+
"notes": "Ground-truth utterance labels not available after WAV update. Distribution derived from pipeline fused output."
|
| 771 |
+
}
|
| 772 |
+
}
|
railway.toml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build]
|
| 2 |
+
builder = "dockerfile"
|
| 3 |
+
|
| 4 |
+
[deploy]
|
| 5 |
+
startCommand = "uvicorn src.stage4.main:app --host 0.0.0.0 --port $PORT"
|
| 6 |
+
healthcheckPath = "/api/health"
|
| 7 |
+
healthcheckTimeout = 300
|
| 8 |
+
restartPolicyType = "on_failure"
|
| 9 |
+
restartPolicyMaxRetries = 3
|
requirements-deploy.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UsTwo Railway Deployment Dependencies
|
| 2 |
+
# Stage 1 (pyannote, whisperx) ์ ์ธ โ ์์ด Stage 2 + Stage 3 + Stage 4 only
|
| 3 |
+
# torch๋ Dockerfile์์ CPU-only๋ก ๋ณ๋ ์ค์น
|
| 4 |
+
|
| 5 |
+
# Stage 4: FastAPI server
|
| 6 |
+
fastapi>=0.100.0
|
| 7 |
+
uvicorn>=0.20.0
|
| 8 |
+
pydantic>=2.0.0
|
| 9 |
+
sqlalchemy>=2.0.0
|
| 10 |
+
python-multipart>=0.0.6
|
| 11 |
+
pyyaml>=6.0
|
| 12 |
+
|
| 13 |
+
# Stage 3: Recap generation
|
| 14 |
+
anthropic>=0.20.0
|
| 15 |
+
|
| 16 |
+
# Quick Recap: Whisper API transcription
|
| 17 |
+
openai>=1.12.0
|
| 18 |
+
|
| 19 |
+
# Stage 1: Speaker diarization + ASR
|
| 20 |
+
pyannote.audio>=3.1
|
| 21 |
+
faster-whisper>=1.0.0
|
| 22 |
+
whisperx>=3.1.0
|
| 23 |
+
torchaudio>=2.0.0
|
| 24 |
+
|
| 25 |
+
# Stage 2: Emotion analysis (English)
|
| 26 |
+
transformers>=4.38.0
|
| 27 |
+
funasr>=1.0.0
|
| 28 |
+
onnxruntime>=1.17.0
|
| 29 |
+
librosa>=0.10.0
|
| 30 |
+
soundfile>=0.12.0
|
| 31 |
+
scipy>=1.10.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.100.0
|
| 2 |
+
uvicorn>=0.20.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
sqlalchemy>=2.0.0
|
| 5 |
+
python-multipart>=0.0.6
|
| 6 |
+
pyyaml>=6.0
|
| 7 |
+
anthropic>=0.20.0
|
| 8 |
+
|
| 9 |
+
# ML โ Stage 2 emotion analysis
|
| 10 |
+
torch>=2.0.0
|
| 11 |
+
transformers>=4.38.0
|
| 12 |
+
funasr>=1.0.0
|
| 13 |
+
onnxruntime>=1.17.0
|
| 14 |
+
librosa>=0.10.0
|
| 15 |
+
soundfile>=0.12.0
|
| 16 |
+
scikit-learn>=1.3.0
|
| 17 |
+
scipy>=1.10.0
|
scripts/add_ravdess_to_english_manifest.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Append RAVDESS fear/disgust/sadness (phone) to the English fusion manifest.
|
| 3 |
+
|
| 4 |
+
Input:
|
| 5 |
+
- data/english_fusion/manifest.json (1,669 samples)
|
| 6 |
+
- data/ravdess/manifest.csv (RAVDESS phone/clean paths)
|
| 7 |
+
|
| 8 |
+
Output:
|
| 9 |
+
- data/english_fusion/manifest_v2.json (2,821 samples)
|
| 10 |
+
|
| 11 |
+
RAVDESS statement โ text:
|
| 12 |
+
1 โ "Kids are talking by the door."
|
| 13 |
+
2 โ "Dogs are sitting by the door."
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import csv
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
from collections import Counter
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
RAVDESS_TARGETS = {"fear", "disgust", "sadness"}
|
| 27 |
+
STATEMENT_TEXT = {
|
| 28 |
+
"1": "Kids are talking by the door.",
|
| 29 |
+
"2": "Dogs are sitting by the door.",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
BASE_MANIFEST = Path("data/english_fusion/manifest.json")
|
| 33 |
+
RAVDESS_CSV = Path("data/ravdess/manifest.csv")
|
| 34 |
+
OUT = Path("data/english_fusion/manifest_v2.json")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_ravdess_rows() -> list[dict]:
|
| 38 |
+
rows: list[dict] = []
|
| 39 |
+
with open(RAVDESS_CSV, newline="") as f:
|
| 40 |
+
reader = csv.DictReader(f)
|
| 41 |
+
for r in reader:
|
| 42 |
+
if r["emotion"] not in RAVDESS_TARGETS:
|
| 43 |
+
continue
|
| 44 |
+
text = STATEMENT_TEXT.get(r["statement"])
|
| 45 |
+
if not text:
|
| 46 |
+
continue
|
| 47 |
+
rows.append({
|
| 48 |
+
"path": r["phone_path"],
|
| 49 |
+
"text": text,
|
| 50 |
+
"label": r["emotion"],
|
| 51 |
+
"source": "ravdess_phone",
|
| 52 |
+
"speaker": f"ravdess_actor_{int(r['actor_id']):02d}",
|
| 53 |
+
})
|
| 54 |
+
return rows
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def main() -> None:
|
| 58 |
+
base = json.loads(BASE_MANIFEST.read_text())
|
| 59 |
+
logger.info("Base manifest: %d samples", len(base))
|
| 60 |
+
|
| 61 |
+
rav = load_ravdess_rows()
|
| 62 |
+
logger.info("RAVDESS additions: %d samples (fear/disgust/sadness)", len(rav))
|
| 63 |
+
|
| 64 |
+
combined = base + rav
|
| 65 |
+
counts = Counter(r["label"] for r in combined)
|
| 66 |
+
sources = Counter(r["source"] for r in combined)
|
| 67 |
+
logger.info("Total v2: %d", len(combined))
|
| 68 |
+
logger.info("By label: %s", dict(counts))
|
| 69 |
+
logger.info("By source: %s", dict(sources))
|
| 70 |
+
|
| 71 |
+
OUT.write_text(json.dumps(combined, indent=2, ensure_ascii=False))
|
| 72 |
+
logger.info("Saved to %s", OUT)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
scripts/asr_savee_disgust_surprise.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run faster-whisper ASR on SAVEE disgust + surprise wavs (120 files).
|
| 3 |
+
|
| 4 |
+
Output: data/savee/savee_asr.json โ {wav_name: transcript}
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
import re
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from faster_whisper import WhisperModel
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
SAVEE_DIR = Path("data/savee/ALL")
|
| 19 |
+
OUT = Path("data/savee/savee_asr.json")
|
| 20 |
+
PATTERN = re.compile(r"^[A-Z]{2}_(d|su)\d+\.wav$")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
wavs = sorted([p for p in SAVEE_DIR.iterdir() if PATTERN.match(p.name)])
|
| 25 |
+
logger.info("Found %d SAVEE disgust/surprise wavs", len(wavs))
|
| 26 |
+
|
| 27 |
+
logger.info("Loading faster-whisper large-v3-turbo (int8, CPU)...")
|
| 28 |
+
model = WhisperModel("large-v3-turbo", device="cpu", compute_type="int8")
|
| 29 |
+
|
| 30 |
+
results: dict[str, str] = {}
|
| 31 |
+
if OUT.exists():
|
| 32 |
+
results = json.loads(OUT.read_text())
|
| 33 |
+
logger.info("Loaded %d cached transcripts", len(results))
|
| 34 |
+
|
| 35 |
+
for i, wav in enumerate(wavs):
|
| 36 |
+
if wav.name in results:
|
| 37 |
+
continue
|
| 38 |
+
segments, _ = model.transcribe(str(wav), language="en", beam_size=5, vad_filter=False)
|
| 39 |
+
text = " ".join(s.text.strip() for s in segments).strip()
|
| 40 |
+
results[wav.name] = text
|
| 41 |
+
if i % 10 == 0:
|
| 42 |
+
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
| 43 |
+
logger.info("[%d/%d] %s -> %s", i + 1, len(wavs), wav.name, text[:60])
|
| 44 |
+
|
| 45 |
+
OUT.write_text(json.dumps(results, indent=2, ensure_ascii=False))
|
| 46 |
+
logger.info("Saved %d transcripts to %s", len(results), OUT)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
main()
|
scripts/benchmark_emotion2vec.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""emotion2vec Variant Smoke Test โ base vs plus_base vs plus_large ์ค์ธก ๋น๊ต.
|
| 3 |
+
|
| 4 |
+
๊ธฐ์กด Stage 1 ์ถ๋ ฅ ์ธ๊ทธ๋จผํธ(88๊ฐ)๋ฅผ ์ฌ์ฉํ์ฌ 3๊ฐ emotion2vec variant์
|
| 5 |
+
latency, RAM, ์์ธก ํ์ง์ ์ค์ธก ๋น๊ตํฉ๋๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/benchmark_emotion2vec.py # 3๊ฐ ์ ๋ถ
|
| 9 |
+
python scripts/benchmark_emotion2vec.py --variants plus_base # ๋จ์ผ
|
| 10 |
+
python scripts/benchmark_emotion2vec.py --device cuda # GPU
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import gc
|
| 17 |
+
import glob
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import os
|
| 21 |
+
import statistics
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import psutil
|
| 28 |
+
|
| 29 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 33 |
+
# Constants
|
| 34 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 35 |
+
|
| 36 |
+
VARIANT_CONFIGS = {
|
| 37 |
+
"base": "iic/emotion2vec_base",
|
| 38 |
+
"plus_base": "iic/emotion2vec_plus_base",
|
| 39 |
+
"plus_large": "iic/emotion2vec_plus_large",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# emotion2vec 9-class โ project 7-class mapping
|
| 43 |
+
LABEL_MAP = {
|
| 44 |
+
"angry": "anger",
|
| 45 |
+
"disgusted": "disgust",
|
| 46 |
+
"fearful": "fear",
|
| 47 |
+
"happy": "joy",
|
| 48 |
+
"neutral": "neutral",
|
| 49 |
+
"sad": "sadness",
|
| 50 |
+
"surprised": "surprise",
|
| 51 |
+
"other": "neutral",
|
| 52 |
+
"unknown": "neutral",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 56 |
+
|
| 57 |
+
# Representative segments for Korean sanity check (indices into sorted segment list)
|
| 58 |
+
# Will be selected dynamically: shortest, longest, and 3 evenly spaced
|
| 59 |
+
SANITY_CHECK_COUNT = 5
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 63 |
+
# Segment Discovery
|
| 64 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 65 |
+
|
| 66 |
+
def discover_segments(segments_dir: str) -> list[dict]:
|
| 67 |
+
"""Find all segment WAV files and load metadata from stage1_output.json."""
|
| 68 |
+
pattern = os.path.join(segments_dir, "call_*", "seg_*.wav")
|
| 69 |
+
paths = sorted(glob.glob(pattern))
|
| 70 |
+
|
| 71 |
+
if not paths:
|
| 72 |
+
logger.error("No segment files found in %s", segments_dir)
|
| 73 |
+
sys.exit(1)
|
| 74 |
+
|
| 75 |
+
# Try to load metadata from stage1_output.json for text context
|
| 76 |
+
metadata = {}
|
| 77 |
+
stage1_path = Path(segments_dir).parent / "stage1_output.json"
|
| 78 |
+
if stage1_path.exists():
|
| 79 |
+
with open(stage1_path) as f:
|
| 80 |
+
data = json.load(f)
|
| 81 |
+
for seg in data.get("segments", []):
|
| 82 |
+
metadata[seg["audio_path"]] = {
|
| 83 |
+
"text": seg.get("text", ""),
|
| 84 |
+
"speaker_id": seg.get("speaker_id", ""),
|
| 85 |
+
"start": seg.get("start", 0),
|
| 86 |
+
"end": seg.get("end", 0),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
segments = []
|
| 90 |
+
for p in paths:
|
| 91 |
+
call_id = Path(p).parent.name
|
| 92 |
+
seg_name = Path(p).stem
|
| 93 |
+
meta = metadata.get(p, {})
|
| 94 |
+
segments.append({
|
| 95 |
+
"path": p,
|
| 96 |
+
"call_id": call_id,
|
| 97 |
+
"seg_name": seg_name,
|
| 98 |
+
"text": meta.get("text", ""),
|
| 99 |
+
"speaker_id": meta.get("speaker_id", ""),
|
| 100 |
+
"duration_sec": meta.get("end", 0) - meta.get("start", 0),
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
logger.info("Discovered %d segments across %d calls",
|
| 104 |
+
len(segments), len(set(s["call_id"] for s in segments)))
|
| 105 |
+
return segments
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def select_sanity_segments(segments: list[dict], count: int = SANITY_CHECK_COUNT) -> list[dict]:
|
| 109 |
+
"""Select representative segments for sanity check: shortest, longest, + evenly spaced."""
|
| 110 |
+
if len(segments) <= count:
|
| 111 |
+
return segments
|
| 112 |
+
|
| 113 |
+
# Sort by duration for selection
|
| 114 |
+
by_dur = sorted(segments, key=lambda s: s["duration_sec"])
|
| 115 |
+
# Filter to segments that have text (from stage1_output.json call)
|
| 116 |
+
with_text = [s for s in by_dur if s["text"]]
|
| 117 |
+
if len(with_text) < count:
|
| 118 |
+
with_text = by_dur
|
| 119 |
+
|
| 120 |
+
selected = [with_text[0], with_text[-1]] # shortest, longest
|
| 121 |
+
remaining = count - 2
|
| 122 |
+
step = max(1, len(with_text) // (remaining + 1))
|
| 123 |
+
for i in range(1, remaining + 1):
|
| 124 |
+
idx = min(i * step, len(with_text) - 1)
|
| 125 |
+
candidate = with_text[idx]
|
| 126 |
+
if candidate not in selected:
|
| 127 |
+
selected.append(candidate)
|
| 128 |
+
|
| 129 |
+
return selected[:count]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 133 |
+
# Benchmarking
|
| 134 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 135 |
+
|
| 136 |
+
def get_process_rss_mb() -> float:
|
| 137 |
+
"""Current process RSS in MB."""
|
| 138 |
+
return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def map_predictions(raw_scores: dict[str, float]) -> dict:
|
| 142 |
+
"""Map emotion2vec native labels to project 7-class taxonomy."""
|
| 143 |
+
mapped = {label: 0.0 for label in PROJECT_LABELS}
|
| 144 |
+
for native_label, score in raw_scores.items():
|
| 145 |
+
project_label = LABEL_MAP.get(native_label, "neutral")
|
| 146 |
+
mapped[project_label] += score
|
| 147 |
+
|
| 148 |
+
top_label = max(mapped, key=mapped.get)
|
| 149 |
+
return {
|
| 150 |
+
"label": top_label,
|
| 151 |
+
"confidence": mapped[top_label],
|
| 152 |
+
"scores": mapped,
|
| 153 |
+
"raw_scores": raw_scores,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def benchmark_variant(
|
| 158 |
+
variant_name: str,
|
| 159 |
+
model_id: str,
|
| 160 |
+
segments: list[dict],
|
| 161 |
+
device: str = "cpu",
|
| 162 |
+
warmup: int = 3,
|
| 163 |
+
) -> dict:
|
| 164 |
+
"""Run full benchmark for one emotion2vec variant."""
|
| 165 |
+
from funasr import AutoModel
|
| 166 |
+
|
| 167 |
+
logger.info("=" * 60)
|
| 168 |
+
logger.info("Benchmarking: %s (%s)", variant_name, model_id)
|
| 169 |
+
logger.info("=" * 60)
|
| 170 |
+
|
| 171 |
+
result = {
|
| 172 |
+
"variant": variant_name,
|
| 173 |
+
"model_id": model_id,
|
| 174 |
+
"device": device,
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
# 1. Baseline RAM
|
| 178 |
+
gc.collect()
|
| 179 |
+
baseline_rss = get_process_rss_mb()
|
| 180 |
+
|
| 181 |
+
# 2. Model load + load time
|
| 182 |
+
logger.info("Loading model...")
|
| 183 |
+
load_start = time.perf_counter()
|
| 184 |
+
try:
|
| 185 |
+
model = AutoModel(model=model_id, device=device)
|
| 186 |
+
except Exception as e:
|
| 187 |
+
logger.error("Failed to load %s: %s", model_id, e)
|
| 188 |
+
result["error"] = str(e)
|
| 189 |
+
return result
|
| 190 |
+
load_time = time.perf_counter() - load_start
|
| 191 |
+
result["load_time_sec"] = round(load_time, 2)
|
| 192 |
+
logger.info("Model loaded in %.2fs", load_time)
|
| 193 |
+
|
| 194 |
+
# 3. Peak RAM after load
|
| 195 |
+
post_load_rss = get_process_rss_mb()
|
| 196 |
+
result["model_ram_mb"] = round(post_load_rss - baseline_rss, 1)
|
| 197 |
+
logger.info("Model RAM: %.1f MB", result["model_ram_mb"])
|
| 198 |
+
|
| 199 |
+
# 4. Warmup
|
| 200 |
+
logger.info("Warmup (%d runs)...", warmup)
|
| 201 |
+
warmup_segs = segments[:warmup] if len(segments) >= warmup else segments
|
| 202 |
+
for seg in warmup_segs:
|
| 203 |
+
try:
|
| 204 |
+
model.generate(seg["path"], granularity="utterance", extract_embedding=False)
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.warning("Warmup failed on %s: %s", seg["path"], e)
|
| 207 |
+
|
| 208 |
+
# 5. Timed inference on all segments
|
| 209 |
+
logger.info("Running inference on %d segments...", len(segments))
|
| 210 |
+
predictions = []
|
| 211 |
+
latencies = []
|
| 212 |
+
errors = []
|
| 213 |
+
peak_rss = post_load_rss
|
| 214 |
+
|
| 215 |
+
for i, seg in enumerate(segments):
|
| 216 |
+
try:
|
| 217 |
+
t0 = time.perf_counter()
|
| 218 |
+
output = model.generate(
|
| 219 |
+
seg["path"], granularity="utterance", extract_embedding=False,
|
| 220 |
+
)
|
| 221 |
+
t1 = time.perf_counter()
|
| 222 |
+
|
| 223 |
+
latency_ms = (t1 - t0) * 1000
|
| 224 |
+
latencies.append(latency_ms)
|
| 225 |
+
|
| 226 |
+
# Parse emotion2vec output
|
| 227 |
+
raw_scores = {}
|
| 228 |
+
if output and isinstance(output, list) and len(output) > 0:
|
| 229 |
+
rec = output[0]
|
| 230 |
+
labels = rec.get("labels", [])
|
| 231 |
+
scores = rec.get("scores", [])
|
| 232 |
+
for label, score in zip(labels, scores):
|
| 233 |
+
raw_scores[label] = float(score)
|
| 234 |
+
|
| 235 |
+
mapped = map_predictions(raw_scores)
|
| 236 |
+
|
| 237 |
+
predictions.append({
|
| 238 |
+
"seg_name": seg["seg_name"],
|
| 239 |
+
"call_id": seg["call_id"],
|
| 240 |
+
"text": seg["text"],
|
| 241 |
+
"speaker_id": seg["speaker_id"],
|
| 242 |
+
"duration_sec": seg["duration_sec"],
|
| 243 |
+
"latency_ms": round(latency_ms, 1),
|
| 244 |
+
**mapped,
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
except Exception as e:
|
| 248 |
+
errors.append({"seg_name": seg["seg_name"], "error": str(e)})
|
| 249 |
+
logger.warning("Inference error on %s: %s", seg["seg_name"], e)
|
| 250 |
+
|
| 251 |
+
# Track peak RAM
|
| 252 |
+
current_rss = get_process_rss_mb()
|
| 253 |
+
peak_rss = max(peak_rss, current_rss)
|
| 254 |
+
|
| 255 |
+
if (i + 1) % 20 == 0:
|
| 256 |
+
logger.info(" %d/%d segments done", i + 1, len(segments))
|
| 257 |
+
|
| 258 |
+
# 6. Aggregate results
|
| 259 |
+
result["peak_ram_mb"] = round(peak_rss - baseline_rss, 1)
|
| 260 |
+
result["total_segments"] = len(segments)
|
| 261 |
+
result["successful"] = len(predictions)
|
| 262 |
+
result["errors"] = errors
|
| 263 |
+
|
| 264 |
+
if latencies:
|
| 265 |
+
result["latency"] = {
|
| 266 |
+
"mean_ms": round(statistics.mean(latencies), 1),
|
| 267 |
+
"median_ms": round(statistics.median(latencies), 1),
|
| 268 |
+
"std_ms": round(statistics.stdev(latencies), 1) if len(latencies) > 1 else 0,
|
| 269 |
+
"p95_ms": round(sorted(latencies)[int(len(latencies) * 0.95)], 1),
|
| 270 |
+
"min_ms": round(min(latencies), 1),
|
| 271 |
+
"max_ms": round(max(latencies), 1),
|
| 272 |
+
}
|
| 273 |
+
else:
|
| 274 |
+
result["latency"] = {}
|
| 275 |
+
|
| 276 |
+
# Emotion distribution
|
| 277 |
+
dist = {label: 0 for label in PROJECT_LABELS}
|
| 278 |
+
for pred in predictions:
|
| 279 |
+
dist[pred["label"]] += 1
|
| 280 |
+
result["emotion_distribution"] = dist
|
| 281 |
+
|
| 282 |
+
result["predictions"] = predictions
|
| 283 |
+
|
| 284 |
+
# 7. Cleanup
|
| 285 |
+
logger.info("Cleaning up model...")
|
| 286 |
+
del model
|
| 287 |
+
gc.collect()
|
| 288 |
+
try:
|
| 289 |
+
import torch
|
| 290 |
+
if torch.cuda.is_available():
|
| 291 |
+
torch.cuda.empty_cache()
|
| 292 |
+
except ImportError:
|
| 293 |
+
pass
|
| 294 |
+
|
| 295 |
+
logger.info("Done: %s โ mean latency %.1fms, peak RAM %.1fMB",
|
| 296 |
+
variant_name,
|
| 297 |
+
result.get("latency", {}).get("mean_ms", 0),
|
| 298 |
+
result.get("peak_ram_mb", 0))
|
| 299 |
+
|
| 300 |
+
return result
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 304 |
+
# Output Formatting
|
| 305 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 306 |
+
|
| 307 |
+
def fmt_table(headers: list[str], rows: list[list[str]], col_widths: list[int] | None = None) -> str:
|
| 308 |
+
"""Simple table formatter."""
|
| 309 |
+
if not col_widths:
|
| 310 |
+
col_widths = []
|
| 311 |
+
for i, h in enumerate(headers):
|
| 312 |
+
max_w = len(h)
|
| 313 |
+
for row in rows:
|
| 314 |
+
if i < len(row):
|
| 315 |
+
max_w = max(max_w, len(str(row[i])))
|
| 316 |
+
col_widths.append(max_w + 2)
|
| 317 |
+
|
| 318 |
+
def fmt_row(cells):
|
| 319 |
+
return "โ " + " โ ".join(str(c).ljust(w) for c, w in zip(cells, col_widths)) + " โ"
|
| 320 |
+
|
| 321 |
+
separator = "โโ" + "โโผโ".join("โ" * w for w in col_widths) + "โโค"
|
| 322 |
+
top = "โโ" + "โโฌโ".join("โ" * w for w in col_widths) + "โโ"
|
| 323 |
+
bottom = "โโ" + "โโดโ".join("โ" * w for w in col_widths) + "โโ"
|
| 324 |
+
|
| 325 |
+
lines = [top, fmt_row(headers), separator]
|
| 326 |
+
for row in rows:
|
| 327 |
+
lines.append(fmt_row(row))
|
| 328 |
+
lines.append(bottom)
|
| 329 |
+
return "\n".join(lines)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def format_results(all_results: dict[str, dict], segments: list[dict]) -> str:
|
| 333 |
+
"""Format benchmark results into readable console output."""
|
| 334 |
+
output_parts = []
|
| 335 |
+
|
| 336 |
+
# โโ Performance Comparison โโ
|
| 337 |
+
output_parts.append("\n=== Performance Comparison ===")
|
| 338 |
+
headers = ["Variant", "Load (s)", "RAM (MB)", "Latency mean (ms)", "Latency p95 (ms)", "Errors"]
|
| 339 |
+
rows = []
|
| 340 |
+
for name, res in all_results.items():
|
| 341 |
+
if "error" in res:
|
| 342 |
+
rows.append([name, "FAIL", "-", "-", "-", res["error"][:40]])
|
| 343 |
+
continue
|
| 344 |
+
lat = res.get("latency", {})
|
| 345 |
+
mean_str = f"{lat.get('mean_ms', 0):.1f} ยฑ {lat.get('std_ms', 0):.1f}"
|
| 346 |
+
rows.append([
|
| 347 |
+
name,
|
| 348 |
+
f"{res.get('load_time_sec', 0):.1f}",
|
| 349 |
+
f"{res.get('peak_ram_mb', 0):.0f}",
|
| 350 |
+
mean_str,
|
| 351 |
+
f"{lat.get('p95_ms', 0):.1f}",
|
| 352 |
+
str(len(res.get("errors", []))),
|
| 353 |
+
])
|
| 354 |
+
output_parts.append(fmt_table(headers, rows))
|
| 355 |
+
|
| 356 |
+
# โโ Knockout Check โโ
|
| 357 |
+
output_parts.append("\n=== Knockout Check ===")
|
| 358 |
+
for name, res in all_results.items():
|
| 359 |
+
if "error" in res:
|
| 360 |
+
output_parts.append(f" {name}: โ LOAD FAILED")
|
| 361 |
+
continue
|
| 362 |
+
lat_mean = res.get("latency", {}).get("mean_ms", 999)
|
| 363 |
+
ram = res.get("peak_ram_mb", 999)
|
| 364 |
+
lat_ok = "โ
" if lat_mean <= 500 else "โ"
|
| 365 |
+
ram_ok = "โ
" if ram <= 2048 else "โ"
|
| 366 |
+
output_parts.append(f" {name}: Latency {lat_ok} ({lat_mean:.0f}ms โค 500ms) RAM {ram_ok} ({ram:.0f}MB โค 2048MB)")
|
| 367 |
+
|
| 368 |
+
# โโ Emotion Distribution โโ
|
| 369 |
+
output_parts.append("\n=== Emotion Distribution (across all segments) ===")
|
| 370 |
+
headers = ["Variant"] + PROJECT_LABELS
|
| 371 |
+
rows = []
|
| 372 |
+
for name, res in all_results.items():
|
| 373 |
+
if "error" in res:
|
| 374 |
+
continue
|
| 375 |
+
dist = res.get("emotion_distribution", {})
|
| 376 |
+
rows.append([name] + [str(dist.get(l, 0)) for l in PROJECT_LABELS])
|
| 377 |
+
output_parts.append(fmt_table(headers, rows))
|
| 378 |
+
|
| 379 |
+
# โโ Korean Sanity Check โโ
|
| 380 |
+
output_parts.append("\n=== Korean Sanity Check ===")
|
| 381 |
+
sanity_segs = select_sanity_segments(segments)
|
| 382 |
+
for seg in sanity_segs:
|
| 383 |
+
text_preview = seg["text"][:50] + "..." if len(seg["text"]) > 50 else seg["text"]
|
| 384 |
+
output_parts.append(f'\n {seg["seg_name"]} ({seg["duration_sec"]:.1f}s): "{text_preview}"')
|
| 385 |
+
for name, res in all_results.items():
|
| 386 |
+
if "error" in res:
|
| 387 |
+
output_parts.append(f" {name}: FAILED")
|
| 388 |
+
continue
|
| 389 |
+
# Find matching prediction
|
| 390 |
+
preds = res.get("predictions", [])
|
| 391 |
+
match = next((p for p in preds if p["seg_name"] == seg["seg_name"]), None)
|
| 392 |
+
if match:
|
| 393 |
+
output_parts.append(f" {name:12s}: {match['label']:10s} ({match['confidence']:.2f})")
|
| 394 |
+
else:
|
| 395 |
+
output_parts.append(f" {name:12s}: no prediction")
|
| 396 |
+
|
| 397 |
+
# โโ Variant Agreement โโ
|
| 398 |
+
output_parts.append("\n=== Variant Agreement ===")
|
| 399 |
+
valid_results = {k: v for k, v in all_results.items() if "error" not in v}
|
| 400 |
+
if len(valid_results) >= 2:
|
| 401 |
+
variant_names = list(valid_results.keys())
|
| 402 |
+
# Build prediction maps: seg_name -> label
|
| 403 |
+
pred_maps = {}
|
| 404 |
+
for name, res in valid_results.items():
|
| 405 |
+
pred_maps[name] = {p["seg_name"]: p["label"] for p in res.get("predictions", [])}
|
| 406 |
+
|
| 407 |
+
# All-agree count
|
| 408 |
+
all_seg_names = set()
|
| 409 |
+
for pm in pred_maps.values():
|
| 410 |
+
all_seg_names.update(pm.keys())
|
| 411 |
+
|
| 412 |
+
agree_count = 0
|
| 413 |
+
total_count = 0
|
| 414 |
+
for seg_name in all_seg_names:
|
| 415 |
+
labels = [pm.get(seg_name) for pm in pred_maps.values() if seg_name in pm]
|
| 416 |
+
if len(labels) == len(valid_results):
|
| 417 |
+
total_count += 1
|
| 418 |
+
if len(set(labels)) == 1:
|
| 419 |
+
agree_count += 1
|
| 420 |
+
|
| 421 |
+
output_parts.append(f" All {len(valid_results)} variants agree: {agree_count}/{total_count} ({agree_count/max(total_count,1)*100:.0f}%)")
|
| 422 |
+
|
| 423 |
+
# Pairwise agreement
|
| 424 |
+
for i in range(len(variant_names)):
|
| 425 |
+
for j in range(i + 1, len(variant_names)):
|
| 426 |
+
a, b = variant_names[i], variant_names[j]
|
| 427 |
+
common = set(pred_maps[a].keys()) & set(pred_maps[b].keys())
|
| 428 |
+
pair_agree = sum(1 for s in common if pred_maps[a][s] == pred_maps[b][s])
|
| 429 |
+
pct = pair_agree / max(len(common), 1) * 100
|
| 430 |
+
output_parts.append(f" {a} vs {b}: {pair_agree}/{len(common)} ({pct:.0f}%)")
|
| 431 |
+
|
| 432 |
+
return "\n".join(output_parts)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 436 |
+
# Main
|
| 437 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 438 |
+
|
| 439 |
+
def main():
|
| 440 |
+
parser = argparse.ArgumentParser(description="emotion2vec variant benchmark")
|
| 441 |
+
parser.add_argument(
|
| 442 |
+
"--variants", nargs="*", default=list(VARIANT_CONFIGS.keys()),
|
| 443 |
+
choices=list(VARIANT_CONFIGS.keys()),
|
| 444 |
+
help="Which variants to benchmark (default: all)",
|
| 445 |
+
)
|
| 446 |
+
parser.add_argument("--segments-dir", default="data/segments", help="Segments directory")
|
| 447 |
+
parser.add_argument("--output-json", default="data/benchmark_results.json", help="Output JSON path")
|
| 448 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], help="Compute device")
|
| 449 |
+
parser.add_argument("--warmup", type=int, default=3, help="Warmup iterations")
|
| 450 |
+
args = parser.parse_args()
|
| 451 |
+
|
| 452 |
+
# Check dependency
|
| 453 |
+
try:
|
| 454 |
+
import funasr # noqa: F401
|
| 455 |
+
except ImportError:
|
| 456 |
+
logger.error("funasr not installed. Run: pip install funasr onnxruntime")
|
| 457 |
+
sys.exit(1)
|
| 458 |
+
|
| 459 |
+
# Discover segments
|
| 460 |
+
segments = discover_segments(args.segments_dir)
|
| 461 |
+
logger.info("Total segments: %d", len(segments))
|
| 462 |
+
|
| 463 |
+
# Run benchmarks
|
| 464 |
+
all_results = {}
|
| 465 |
+
for variant_name in args.variants:
|
| 466 |
+
model_id = VARIANT_CONFIGS[variant_name]
|
| 467 |
+
result = benchmark_variant(
|
| 468 |
+
variant_name, model_id, segments,
|
| 469 |
+
device=args.device, warmup=args.warmup,
|
| 470 |
+
)
|
| 471 |
+
all_results[variant_name] = result
|
| 472 |
+
|
| 473 |
+
# Format and print results
|
| 474 |
+
report = format_results(all_results, segments)
|
| 475 |
+
print(report)
|
| 476 |
+
|
| 477 |
+
# Save JSON
|
| 478 |
+
output_path = Path(args.output_json)
|
| 479 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 480 |
+
|
| 481 |
+
# Get system info
|
| 482 |
+
import platform
|
| 483 |
+
try:
|
| 484 |
+
import torch
|
| 485 |
+
torch_version = torch.__version__
|
| 486 |
+
cuda_available = torch.cuda.is_available()
|
| 487 |
+
except ImportError:
|
| 488 |
+
torch_version = "not installed"
|
| 489 |
+
cuda_available = False
|
| 490 |
+
|
| 491 |
+
output_data = {
|
| 492 |
+
"metadata": {
|
| 493 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 494 |
+
"device": args.device,
|
| 495 |
+
"total_segments": len(segments),
|
| 496 |
+
"python_version": platform.python_version(),
|
| 497 |
+
"torch_version": torch_version,
|
| 498 |
+
"cuda_available": cuda_available,
|
| 499 |
+
"cpu": platform.processor() or "unknown",
|
| 500 |
+
"ram_total_gb": round(psutil.virtual_memory().total / (1024**3), 1),
|
| 501 |
+
},
|
| 502 |
+
"results": all_results,
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 506 |
+
json.dump(output_data, f, indent=2, ensure_ascii=False, default=str)
|
| 507 |
+
|
| 508 |
+
logger.info("Results saved to %s", output_path)
|
| 509 |
+
print(f"\nFull results saved to {output_path}")
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
if __name__ == "__main__":
|
| 513 |
+
main()
|
scripts/benchmark_ser_models.py
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""3-Model SER Benchmark โ emotion2vec vs SpeechBrain vs Whisper+Head.
|
| 3 |
+
|
| 4 |
+
AI Hub ํ๊ตญ์ด ๊ฐ์ ๋ฐ์ดํฐ์
ํ
์คํธ ์๋ธ์
์ ์ฌ์ฉํ์ฌ 3๊ฐ ๋ชจ๋ธ์
|
| 5 |
+
์ ํ๋, ๋ ์ดํด์, ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋์ ๊ฐ๊ด์ ์ผ๋ก ๋น๊ตํ๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
# 2๊ฐ ๋ชจ๋ธ ๋จผ์ (Whisper head ์์ด)
|
| 9 |
+
python scripts/benchmark_ser_models.py \\
|
| 10 |
+
--test-dir data/evaluation/korean \\
|
| 11 |
+
--models emotion2vec speechbrain
|
| 12 |
+
|
| 13 |
+
# ์ ์ฒด 3๊ฐ ๋ชจ๋ธ
|
| 14 |
+
python scripts/benchmark_ser_models.py \\
|
| 15 |
+
--test-dir data/evaluation/korean \\
|
| 16 |
+
--models emotion2vec speechbrain whisper \\
|
| 17 |
+
--whisper-head-ckpt data/models/whisper_emotion_head.pt
|
| 18 |
+
|
| 19 |
+
# Quick smoke test
|
| 20 |
+
python scripts/benchmark_ser_models.py \\
|
| 21 |
+
--test-dir data/evaluation/korean \\
|
| 22 |
+
--models emotion2vec --max-samples 10
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import csv
|
| 29 |
+
import gc
|
| 30 |
+
import json
|
| 31 |
+
import logging
|
| 32 |
+
import os
|
| 33 |
+
import statistics
|
| 34 |
+
import sys
|
| 35 |
+
import tempfile
|
| 36 |
+
import time
|
| 37 |
+
from abc import ABC, abstractmethod
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
import psutil
|
| 42 |
+
import soundfile as sf
|
| 43 |
+
|
| 44 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 48 |
+
# Constants
|
| 49 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 50 |
+
|
| 51 |
+
EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
|
| 52 |
+
|
| 53 |
+
# Knockout criteria (from evaluation-framework.md)
|
| 54 |
+
KNOCKOUT_F1 = 0.70
|
| 55 |
+
KNOCKOUT_LATENCY_MS = 500
|
| 56 |
+
KNOCKOUT_RAM_MB = 2048
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 60 |
+
# Model Adapter Interface
|
| 61 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 62 |
+
|
| 63 |
+
class SERModelAdapter(ABC):
|
| 64 |
+
"""Abstract base for SER model adapters."""
|
| 65 |
+
|
| 66 |
+
name: str
|
| 67 |
+
model_id: str
|
| 68 |
+
params_m: int # millions
|
| 69 |
+
|
| 70 |
+
@abstractmethod
|
| 71 |
+
def load(self, device: str) -> None:
|
| 72 |
+
...
|
| 73 |
+
|
| 74 |
+
@abstractmethod
|
| 75 |
+
def predict(self, audio_path: str) -> dict[str, float]:
|
| 76 |
+
"""Return {emotion_label: score} in project taxonomy."""
|
| 77 |
+
...
|
| 78 |
+
|
| 79 |
+
@abstractmethod
|
| 80 |
+
def unload(self) -> None:
|
| 81 |
+
...
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 85 |
+
# Adapter 1: emotion2vec_plus_base
|
| 86 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 87 |
+
|
| 88 |
+
class Emotion2vecAdapter(SERModelAdapter):
|
| 89 |
+
name = "emotion2vec_plus_base"
|
| 90 |
+
model_id = "iic/emotion2vec_plus_base"
|
| 91 |
+
params_m = 90
|
| 92 |
+
|
| 93 |
+
# emotion2vec 9-class โ project 7-class (from src/stage2/audio_emotion.py)
|
| 94 |
+
LABEL_MAP = {
|
| 95 |
+
"angry": "anger", "disgusted": "disgust", "fearful": "fear",
|
| 96 |
+
"happy": "joy", "neutral": "neutral", "sad": "sadness",
|
| 97 |
+
"surprised": "surprise", "other": "neutral", "unknown": "neutral",
|
| 98 |
+
"็ๆฐ/angry": "anger", "ๅๆถ/disgusted": "disgust",
|
| 99 |
+
"ๆๆง/fearful": "fear", "ๅผๅฟ/happy": "joy",
|
| 100 |
+
"ไธญ็ซ/neutral": "neutral", "้พ่ฟ/sad": "sadness",
|
| 101 |
+
"ๅๆ/surprised": "surprise", "ๅ
ถไป/other": "neutral", "<unk>": "neutral",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
def __init__(self):
|
| 105 |
+
self._model = None
|
| 106 |
+
|
| 107 |
+
def load(self, device: str) -> None:
|
| 108 |
+
from funasr import AutoModel
|
| 109 |
+
self._model = AutoModel(model=self.model_id, device=device, hub="hf")
|
| 110 |
+
|
| 111 |
+
def predict(self, audio_path: str) -> dict[str, float]:
|
| 112 |
+
output = self._model.generate(
|
| 113 |
+
audio_path, granularity="utterance", extract_embedding=False,
|
| 114 |
+
)
|
| 115 |
+
scores = {label: 0.0 for label in EVAL_LABELS}
|
| 116 |
+
if output and isinstance(output, list) and len(output) > 0:
|
| 117 |
+
rec = output[0]
|
| 118 |
+
for native_label, score in zip(rec.get("labels", []), rec.get("scores", [])):
|
| 119 |
+
mapped = self.LABEL_MAP.get(native_label, "neutral")
|
| 120 |
+
if mapped in scores:
|
| 121 |
+
scores[mapped] += float(score)
|
| 122 |
+
# Normalize
|
| 123 |
+
total = sum(scores.values())
|
| 124 |
+
if total > 0:
|
| 125 |
+
scores = {k: v / total for k, v in scores.items()}
|
| 126 |
+
return scores
|
| 127 |
+
|
| 128 |
+
def unload(self) -> None:
|
| 129 |
+
del self._model
|
| 130 |
+
self._model = None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 134 |
+
# Adapter 2: SpeechBrain wav2vec2-IEMOCAP
|
| 135 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 136 |
+
|
| 137 |
+
class SpeechBrainAdapter(SERModelAdapter):
|
| 138 |
+
name = "speechbrain_wav2vec2"
|
| 139 |
+
model_id = "speechbrain/emotion-recognition-wav2vec2-IEMOCAP"
|
| 140 |
+
params_m = 314
|
| 141 |
+
|
| 142 |
+
# SpeechBrain 4-class โ project taxonomy
|
| 143 |
+
# NOTE: This model CANNOT predict fear or surprise
|
| 144 |
+
LABEL_MAP = {
|
| 145 |
+
"ang": "anger",
|
| 146 |
+
"hap": "joy",
|
| 147 |
+
"sad": "sadness",
|
| 148 |
+
"neu": "neutral",
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
def __init__(self):
|
| 152 |
+
self._classifier = None
|
| 153 |
+
self._label_order = None # populated from label_encoder
|
| 154 |
+
|
| 155 |
+
def load(self, device: str) -> None:
|
| 156 |
+
import torch
|
| 157 |
+
from speechbrain.inference.classifiers import EncoderClassifier
|
| 158 |
+
self._classifier = EncoderClassifier.from_hparams(
|
| 159 |
+
source=self.model_id,
|
| 160 |
+
run_opts={"device": device},
|
| 161 |
+
)
|
| 162 |
+
self._classifier = self._classifier.to(device)
|
| 163 |
+
|
| 164 |
+
# Get label order from label_encoder
|
| 165 |
+
try:
|
| 166 |
+
le = self._classifier.hparams.label_encoder
|
| 167 |
+
# lab2ind: {'neu': 0, 'ang': 1, 'hap': 2, 'sad': 3}
|
| 168 |
+
self._label_order = [None] * len(le.lab2ind)
|
| 169 |
+
for lab, idx in le.lab2ind.items():
|
| 170 |
+
self._label_order[idx] = lab
|
| 171 |
+
logger.info("SpeechBrain labels: %s", self._label_order)
|
| 172 |
+
except Exception:
|
| 173 |
+
self._label_order = ["neu", "ang", "hap", "sad"]
|
| 174 |
+
|
| 175 |
+
def predict(self, audio_path: str) -> dict[str, float]:
|
| 176 |
+
import torch
|
| 177 |
+
import torchaudio
|
| 178 |
+
|
| 179 |
+
signal, sr = torchaudio.load(audio_path)
|
| 180 |
+
if sr != 16000:
|
| 181 |
+
signal = torchaudio.functional.resample(signal, sr, 16000)
|
| 182 |
+
if signal.shape[0] > 1:
|
| 183 |
+
signal = signal.mean(dim=0, keepdim=True)
|
| 184 |
+
|
| 185 |
+
# Use modules directly (classify_batch broken in SpeechBrain 1.1.0)
|
| 186 |
+
with torch.no_grad():
|
| 187 |
+
feats = self._classifier.mods.wav2vec2(signal)
|
| 188 |
+
pooled = self._classifier.mods.avg_pool(feats)
|
| 189 |
+
logits = self._classifier.mods.output_mlp(pooled)
|
| 190 |
+
probs = torch.softmax(logits.squeeze(1), dim=-1).squeeze().tolist()
|
| 191 |
+
|
| 192 |
+
if isinstance(probs, float):
|
| 193 |
+
probs = [probs]
|
| 194 |
+
|
| 195 |
+
scores = {label: 0.0 for label in EVAL_LABELS}
|
| 196 |
+
for sb_label, prob in zip(self._label_order, probs):
|
| 197 |
+
mapped = self.LABEL_MAP.get(sb_label, "neutral")
|
| 198 |
+
if mapped in scores:
|
| 199 |
+
scores[mapped] += prob
|
| 200 |
+
|
| 201 |
+
return scores
|
| 202 |
+
|
| 203 |
+
def unload(self) -> None:
|
| 204 |
+
del self._classifier
|
| 205 |
+
self._classifier = None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 209 |
+
# Adapter 3: Whisper-Medium + Emotion Head
|
| 210 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 211 |
+
|
| 212 |
+
class WhisperMediumAdapter(SERModelAdapter):
|
| 213 |
+
name = "whisper_medium_head"
|
| 214 |
+
model_id = "openai/whisper-medium"
|
| 215 |
+
params_m = 769
|
| 216 |
+
|
| 217 |
+
def __init__(self, head_ckpt: str | None = None):
|
| 218 |
+
self._encoder = None
|
| 219 |
+
self._head = None
|
| 220 |
+
self._processor = None
|
| 221 |
+
self._head_ckpt = head_ckpt
|
| 222 |
+
self._device = "cpu"
|
| 223 |
+
|
| 224 |
+
def load(self, device: str) -> None:
|
| 225 |
+
import torch
|
| 226 |
+
from transformers import WhisperModel, WhisperFeatureExtractor
|
| 227 |
+
|
| 228 |
+
self._device = device
|
| 229 |
+
self._processor = WhisperFeatureExtractor.from_pretrained(self.model_id)
|
| 230 |
+
self._encoder = WhisperModel.from_pretrained(self.model_id).to(device)
|
| 231 |
+
self._encoder.eval()
|
| 232 |
+
|
| 233 |
+
# Classifier head: hidden_dim โ 6 classes
|
| 234 |
+
hidden_dim = self._encoder.config.d_model # 1024 for medium
|
| 235 |
+
self._head = torch.nn.Linear(hidden_dim, len(EVAL_LABELS)).to(device)
|
| 236 |
+
|
| 237 |
+
if self._head_ckpt and Path(self._head_ckpt).exists():
|
| 238 |
+
logger.info("Loading Whisper emotion head from %s", self._head_ckpt)
|
| 239 |
+
state = torch.load(self._head_ckpt, map_location=device, weights_only=True)
|
| 240 |
+
self._head.load_state_dict(state)
|
| 241 |
+
else:
|
| 242 |
+
logger.warning("No trained Whisper head โ using random weights (baseline)")
|
| 243 |
+
|
| 244 |
+
self._head.eval()
|
| 245 |
+
|
| 246 |
+
def predict(self, audio_path: str) -> dict[str, float]:
|
| 247 |
+
import torch
|
| 248 |
+
import librosa
|
| 249 |
+
|
| 250 |
+
# Load and preprocess
|
| 251 |
+
audio, sr = librosa.load(audio_path, sr=16000)
|
| 252 |
+
inputs = self._processor(
|
| 253 |
+
audio, sampling_rate=16000, return_tensors="pt",
|
| 254 |
+
)
|
| 255 |
+
input_features = inputs.input_features.to(self._device)
|
| 256 |
+
|
| 257 |
+
with torch.no_grad():
|
| 258 |
+
encoder_out = self._encoder.encoder(input_features)
|
| 259 |
+
hidden = encoder_out.last_hidden_state # (1, T, D)
|
| 260 |
+
pooled = hidden.mean(dim=1) # (1, D)
|
| 261 |
+
logits = self._head(pooled) # (1, 6)
|
| 262 |
+
probs = torch.softmax(logits, dim=-1).squeeze().cpu().tolist()
|
| 263 |
+
|
| 264 |
+
scores = {}
|
| 265 |
+
for label, prob in zip(EVAL_LABELS, probs):
|
| 266 |
+
scores[label] = prob
|
| 267 |
+
return scores
|
| 268 |
+
|
| 269 |
+
def unload(self) -> None:
|
| 270 |
+
del self._encoder, self._head, self._processor
|
| 271 |
+
self._encoder = self._head = self._processor = None
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 275 |
+
# Phone Augmentation
|
| 276 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 277 |
+
|
| 278 |
+
def apply_phone_augmentation(audio_path: str) -> str:
|
| 279 |
+
"""Apply phone-quality degradation, return path to temp WAV file."""
|
| 280 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
| 281 |
+
from common.phone_simulator import PhoneSimulator, CompandingType
|
| 282 |
+
|
| 283 |
+
audio, sr = sf.read(audio_path, dtype="float32")
|
| 284 |
+
if audio.ndim == 2:
|
| 285 |
+
audio = audio.mean(axis=1)
|
| 286 |
+
|
| 287 |
+
sim = PhoneSimulator(companding=CompandingType.ALAW)
|
| 288 |
+
degraded, new_sr = sim.process(audio, sr)
|
| 289 |
+
|
| 290 |
+
# Save to temp file
|
| 291 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
| 292 |
+
sf.write(tmp.name, degraded, new_sr, subtype="PCM_16")
|
| 293 |
+
return tmp.name
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 297 |
+
# Test Data Loading
|
| 298 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 299 |
+
|
| 300 |
+
def load_test_data(test_dir: str, max_samples: int | None = None) -> list[dict]:
|
| 301 |
+
"""Load test samples from prepared subset."""
|
| 302 |
+
csv_path = Path(test_dir) / "test_labels.csv"
|
| 303 |
+
if not csv_path.exists():
|
| 304 |
+
logger.error("test_labels.csv not found in %s", test_dir)
|
| 305 |
+
sys.exit(1)
|
| 306 |
+
|
| 307 |
+
samples = []
|
| 308 |
+
with open(csv_path, encoding="utf-8") as f:
|
| 309 |
+
reader = csv.DictReader(f)
|
| 310 |
+
for row in reader:
|
| 311 |
+
audio_path = str(Path(test_dir) / row["file_path"])
|
| 312 |
+
if not Path(audio_path).exists():
|
| 313 |
+
logger.warning("Audio file not found: %s", audio_path)
|
| 314 |
+
continue
|
| 315 |
+
samples.append({
|
| 316 |
+
"audio_path": audio_path,
|
| 317 |
+
"emotion": row["emotion"],
|
| 318 |
+
"duration": float(row["duration"]),
|
| 319 |
+
"speaker_id": row.get("speaker_id", ""),
|
| 320 |
+
"intensity": row.get("intensity", ""),
|
| 321 |
+
})
|
| 322 |
+
|
| 323 |
+
if max_samples and len(samples) > max_samples:
|
| 324 |
+
import random
|
| 325 |
+
random.seed(42)
|
| 326 |
+
samples = random.sample(samples, max_samples)
|
| 327 |
+
|
| 328 |
+
logger.info("Loaded %d test samples from %s", len(samples), test_dir)
|
| 329 |
+
return samples
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 333 |
+
# Benchmark Runner
|
| 334 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 335 |
+
|
| 336 |
+
def get_process_rss_mb() -> float:
|
| 337 |
+
return psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def benchmark_model(
|
| 341 |
+
adapter: SERModelAdapter,
|
| 342 |
+
samples: list[dict],
|
| 343 |
+
device: str,
|
| 344 |
+
phone_augment: bool,
|
| 345 |
+
warmup: int = 5,
|
| 346 |
+
) -> dict:
|
| 347 |
+
"""Run full benchmark for one model on both clean and optionally phone conditions."""
|
| 348 |
+
logger.info("=" * 60)
|
| 349 |
+
logger.info("Benchmarking: %s (%dM params)", adapter.name, adapter.params_m)
|
| 350 |
+
logger.info("=" * 60)
|
| 351 |
+
|
| 352 |
+
result = {
|
| 353 |
+
"model": adapter.name,
|
| 354 |
+
"model_id": adapter.model_id,
|
| 355 |
+
"params_m": adapter.params_m,
|
| 356 |
+
"device": device,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
# Baseline RAM
|
| 360 |
+
gc.collect()
|
| 361 |
+
baseline_rss = get_process_rss_mb()
|
| 362 |
+
|
| 363 |
+
# Load model
|
| 364 |
+
logger.info("Loading model...")
|
| 365 |
+
load_start = time.perf_counter()
|
| 366 |
+
try:
|
| 367 |
+
adapter.load(device)
|
| 368 |
+
except Exception as e:
|
| 369 |
+
logger.error("Failed to load %s: %s", adapter.name, e)
|
| 370 |
+
result["error"] = str(e)
|
| 371 |
+
return result
|
| 372 |
+
load_time = time.perf_counter() - load_start
|
| 373 |
+
result["load_time_sec"] = round(load_time, 2)
|
| 374 |
+
|
| 375 |
+
post_load_rss = get_process_rss_mb()
|
| 376 |
+
result["model_ram_mb"] = round(post_load_rss - baseline_rss, 1)
|
| 377 |
+
logger.info("Loaded in %.1fs, RAM: %.0fMB", load_time, result["model_ram_mb"])
|
| 378 |
+
|
| 379 |
+
# Run for each condition
|
| 380 |
+
conditions = ["clean"]
|
| 381 |
+
if phone_augment:
|
| 382 |
+
conditions.append("phone")
|
| 383 |
+
|
| 384 |
+
for condition in conditions:
|
| 385 |
+
logger.info("--- Condition: %s ---", condition)
|
| 386 |
+
|
| 387 |
+
# Warmup
|
| 388 |
+
warmup_samples = samples[:warmup] if len(samples) >= warmup else samples
|
| 389 |
+
for s in warmup_samples:
|
| 390 |
+
try:
|
| 391 |
+
audio_path = s["audio_path"]
|
| 392 |
+
if condition == "phone":
|
| 393 |
+
audio_path = apply_phone_augmentation(audio_path)
|
| 394 |
+
adapter.predict(audio_path)
|
| 395 |
+
if condition == "phone":
|
| 396 |
+
os.unlink(audio_path)
|
| 397 |
+
except Exception:
|
| 398 |
+
pass
|
| 399 |
+
|
| 400 |
+
# Inference
|
| 401 |
+
y_true = []
|
| 402 |
+
y_pred = []
|
| 403 |
+
latencies = []
|
| 404 |
+
errors = []
|
| 405 |
+
peak_rss = get_process_rss_mb()
|
| 406 |
+
|
| 407 |
+
for i, sample in enumerate(samples):
|
| 408 |
+
audio_path = sample["audio_path"]
|
| 409 |
+
tmp_path = None
|
| 410 |
+
|
| 411 |
+
try:
|
| 412 |
+
if condition == "phone":
|
| 413 |
+
tmp_path = apply_phone_augmentation(audio_path)
|
| 414 |
+
audio_path = tmp_path
|
| 415 |
+
|
| 416 |
+
t0 = time.perf_counter()
|
| 417 |
+
scores = adapter.predict(audio_path)
|
| 418 |
+
t1 = time.perf_counter()
|
| 419 |
+
|
| 420 |
+
latency_ms = (t1 - t0) * 1000
|
| 421 |
+
latencies.append(latency_ms)
|
| 422 |
+
|
| 423 |
+
pred_label = max(scores, key=scores.get)
|
| 424 |
+
y_true.append(sample["emotion"])
|
| 425 |
+
y_pred.append(pred_label)
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
errors.append({"index": i, "error": str(e)})
|
| 429 |
+
logger.warning("Error on sample %d: %s", i, e)
|
| 430 |
+
finally:
|
| 431 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 432 |
+
os.unlink(tmp_path)
|
| 433 |
+
|
| 434 |
+
current_rss = get_process_rss_mb()
|
| 435 |
+
peak_rss = max(peak_rss, current_rss)
|
| 436 |
+
|
| 437 |
+
if (i + 1) % 50 == 0:
|
| 438 |
+
logger.info(" %d/%d done (mean lat: %.0fms)", i + 1, len(samples),
|
| 439 |
+
statistics.mean(latencies) if latencies else 0)
|
| 440 |
+
|
| 441 |
+
# Compute metrics
|
| 442 |
+
cond_result = compute_metrics(y_true, y_pred, latencies, peak_rss - baseline_rss, errors)
|
| 443 |
+
result[condition] = cond_result
|
| 444 |
+
|
| 445 |
+
logger.info(" %s: macro_f1=%.3f, accuracy=%.3f, mean_latency=%.0fms, peak_ram=%.0fMB",
|
| 446 |
+
condition,
|
| 447 |
+
cond_result["macro_f1"],
|
| 448 |
+
cond_result["accuracy"],
|
| 449 |
+
cond_result["latency"]["mean_ms"],
|
| 450 |
+
cond_result["peak_ram_mb"])
|
| 451 |
+
|
| 452 |
+
# Unload
|
| 453 |
+
adapter.unload()
|
| 454 |
+
gc.collect()
|
| 455 |
+
try:
|
| 456 |
+
import torch
|
| 457 |
+
if torch.cuda.is_available():
|
| 458 |
+
torch.cuda.empty_cache()
|
| 459 |
+
except ImportError:
|
| 460 |
+
pass
|
| 461 |
+
|
| 462 |
+
return result
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 466 |
+
# Metrics
|
| 467 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 468 |
+
|
| 469 |
+
def compute_metrics(
|
| 470 |
+
y_true: list[str],
|
| 471 |
+
y_pred: list[str],
|
| 472 |
+
latencies: list[float],
|
| 473 |
+
peak_ram_mb: float,
|
| 474 |
+
errors: list[dict],
|
| 475 |
+
) -> dict:
|
| 476 |
+
"""Compute accuracy, F1, confusion matrix, latency stats."""
|
| 477 |
+
from sklearn.metrics import (
|
| 478 |
+
accuracy_score,
|
| 479 |
+
precision_recall_fscore_support,
|
| 480 |
+
confusion_matrix,
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
if not y_true or not y_pred:
|
| 484 |
+
return {
|
| 485 |
+
"accuracy": 0.0, "macro_f1": 0.0, "weighted_f1": 0.0,
|
| 486 |
+
"per_class": {l: {"precision": 0, "recall": 0, "f1": 0, "support": 0} for l in EVAL_LABELS},
|
| 487 |
+
"confusion_matrix": [[0] * len(EVAL_LABELS)] * len(EVAL_LABELS),
|
| 488 |
+
"confusion_labels": EVAL_LABELS,
|
| 489 |
+
"latency": {}, "peak_ram_mb": round(peak_ram_mb, 1),
|
| 490 |
+
"total_samples": 0, "errors": errors,
|
| 491 |
+
"note": "All samples failed โ no predictions available",
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
accuracy = accuracy_score(y_true, y_pred)
|
| 495 |
+
precision, recall, f1, support = precision_recall_fscore_support(
|
| 496 |
+
y_true, y_pred, labels=EVAL_LABELS, average=None, zero_division=0,
|
| 497 |
+
)
|
| 498 |
+
macro_f1 = float(np.mean(f1))
|
| 499 |
+
weighted_f1 = float(np.average(f1, weights=support)) if sum(support) > 0 else 0.0
|
| 500 |
+
|
| 501 |
+
cm = confusion_matrix(y_true, y_pred, labels=EVAL_LABELS).tolist()
|
| 502 |
+
|
| 503 |
+
per_class = {}
|
| 504 |
+
for i, label in enumerate(EVAL_LABELS):
|
| 505 |
+
per_class[label] = {
|
| 506 |
+
"precision": round(float(precision[i]), 4),
|
| 507 |
+
"recall": round(float(recall[i]), 4),
|
| 508 |
+
"f1": round(float(f1[i]), 4),
|
| 509 |
+
"support": int(support[i]),
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
latency_stats = {}
|
| 513 |
+
if latencies:
|
| 514 |
+
latency_stats = {
|
| 515 |
+
"mean_ms": round(statistics.mean(latencies), 1),
|
| 516 |
+
"median_ms": round(statistics.median(latencies), 1),
|
| 517 |
+
"std_ms": round(statistics.stdev(latencies), 1) if len(latencies) > 1 else 0,
|
| 518 |
+
"p95_ms": round(sorted(latencies)[int(len(latencies) * 0.95)], 1),
|
| 519 |
+
"min_ms": round(min(latencies), 1),
|
| 520 |
+
"max_ms": round(max(latencies), 1),
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
return {
|
| 524 |
+
"accuracy": round(accuracy, 4),
|
| 525 |
+
"macro_f1": round(macro_f1, 4),
|
| 526 |
+
"weighted_f1": round(weighted_f1, 4),
|
| 527 |
+
"per_class": per_class,
|
| 528 |
+
"confusion_matrix": cm,
|
| 529 |
+
"confusion_labels": EVAL_LABELS,
|
| 530 |
+
"latency": latency_stats,
|
| 531 |
+
"peak_ram_mb": round(peak_ram_mb, 1),
|
| 532 |
+
"total_samples": len(y_true),
|
| 533 |
+
"errors": errors,
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 538 |
+
# Knockout Check
|
| 539 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 540 |
+
|
| 541 |
+
def knockout_check(result: dict) -> dict:
|
| 542 |
+
"""Check if model passes knockout criteria."""
|
| 543 |
+
checks = {}
|
| 544 |
+
for condition in ["clean", "phone"]:
|
| 545 |
+
if condition not in result:
|
| 546 |
+
continue
|
| 547 |
+
cond = result[condition]
|
| 548 |
+
f1_ok = cond["macro_f1"] >= KNOCKOUT_F1
|
| 549 |
+
lat_ok = cond["latency"].get("mean_ms", 999) <= KNOCKOUT_LATENCY_MS
|
| 550 |
+
ram_ok = cond["peak_ram_mb"] <= KNOCKOUT_RAM_MB
|
| 551 |
+
checks[condition] = {
|
| 552 |
+
"korean_f1": f"{'PASS' if f1_ok else 'FAIL'} ({cond['macro_f1']:.3f} {'โฅ' if f1_ok else '<'} {KNOCKOUT_F1})",
|
| 553 |
+
"latency": f"{'PASS' if lat_ok else 'FAIL'} ({cond['latency'].get('mean_ms', 0):.0f}ms {'โค' if lat_ok else '>'} {KNOCKOUT_LATENCY_MS}ms)",
|
| 554 |
+
"ram": f"{'PASS' if ram_ok else 'FAIL'} ({cond['peak_ram_mb']:.0f}MB {'โค' if ram_ok else '>'} {KNOCKOUT_RAM_MB}MB)",
|
| 555 |
+
"overall": "PASS" if (f1_ok and lat_ok and ram_ok) else "FAIL",
|
| 556 |
+
}
|
| 557 |
+
return checks
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 561 |
+
# Report Generation
|
| 562 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 563 |
+
|
| 564 |
+
def generate_markdown_report(all_results: dict, output_path: str):
|
| 565 |
+
"""Generate a markdown comparison report."""
|
| 566 |
+
lines = [
|
| 567 |
+
"# 3-Model SER Benchmark Report",
|
| 568 |
+
"",
|
| 569 |
+
f"**Generated**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
| 570 |
+
f"**Dataset**: AI Hub #71631 (๊ฐ์ ์ด ํ๊น
๋ ์์ ๋ํ - ์ฑ์ธ)",
|
| 571 |
+
f"**Evaluation Classes**: {', '.join(EVAL_LABELS)} (6-class, no disgust)",
|
| 572 |
+
"",
|
| 573 |
+
"---",
|
| 574 |
+
"",
|
| 575 |
+
"## Summary Comparison",
|
| 576 |
+
"",
|
| 577 |
+
]
|
| 578 |
+
|
| 579 |
+
# Summary table
|
| 580 |
+
headers = ["Model", "Params", "Clean F1", "Phone F1", "Latency (mean)", "Latency (p95)", "RAM", "Knockout"]
|
| 581 |
+
rows = []
|
| 582 |
+
for name, res in all_results.items():
|
| 583 |
+
if "error" in res:
|
| 584 |
+
rows.append(f"| {name} | {res.get('params_m', '?')}M | LOAD FAILED | - | - | - | - | FAIL |")
|
| 585 |
+
continue
|
| 586 |
+
clean = res.get("clean", {})
|
| 587 |
+
phone = res.get("phone", {})
|
| 588 |
+
ko = knockout_check(res)
|
| 589 |
+
clean_ko = ko.get("clean", {}).get("overall", "N/A")
|
| 590 |
+
rows.append(
|
| 591 |
+
f"| {name} | {res['params_m']}M "
|
| 592 |
+
f"| {clean.get('macro_f1', 0):.3f} "
|
| 593 |
+
f"| {phone.get('macro_f1', 'N/A') if phone else 'N/A'} "
|
| 594 |
+
f"| {clean.get('latency', {}).get('mean_ms', 0):.0f}ms "
|
| 595 |
+
f"| {clean.get('latency', {}).get('p95_ms', 0):.0f}ms "
|
| 596 |
+
f"| {clean.get('peak_ram_mb', 0):.0f}MB "
|
| 597 |
+
f"| {clean_ko} |"
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
lines.append(f"| {' | '.join(headers)} |")
|
| 601 |
+
lines.append(f"| {'---|' * len(headers)}")
|
| 602 |
+
lines.extend(rows)
|
| 603 |
+
lines.append("")
|
| 604 |
+
|
| 605 |
+
# Knockout details
|
| 606 |
+
lines.extend(["", "## Knockout Check", ""])
|
| 607 |
+
for name, res in all_results.items():
|
| 608 |
+
if "error" in res:
|
| 609 |
+
continue
|
| 610 |
+
ko = knockout_check(res)
|
| 611 |
+
lines.append(f"### {name}")
|
| 612 |
+
for cond, checks in ko.items():
|
| 613 |
+
lines.append(f"**{cond}**: {checks['overall']}")
|
| 614 |
+
lines.append(f" - F1: {checks['korean_f1']}")
|
| 615 |
+
lines.append(f" - Latency: {checks['latency']}")
|
| 616 |
+
lines.append(f" - RAM: {checks['ram']}")
|
| 617 |
+
lines.append("")
|
| 618 |
+
|
| 619 |
+
# Per-model details with confusion matrix
|
| 620 |
+
lines.extend(["## Per-Model Details", ""])
|
| 621 |
+
for name, res in all_results.items():
|
| 622 |
+
if "error" in res:
|
| 623 |
+
continue
|
| 624 |
+
lines.append(f"### {name}")
|
| 625 |
+
|
| 626 |
+
for condition in ["clean", "phone"]:
|
| 627 |
+
if condition not in res:
|
| 628 |
+
continue
|
| 629 |
+
cond = res[condition]
|
| 630 |
+
lines.extend([
|
| 631 |
+
f"",
|
| 632 |
+
f"#### {condition.title()} Condition",
|
| 633 |
+
f"",
|
| 634 |
+
f"- Accuracy: {cond['accuracy']:.3f}",
|
| 635 |
+
f"- Macro F1: {cond['macro_f1']:.3f}",
|
| 636 |
+
f"- Weighted F1: {cond['weighted_f1']:.3f}",
|
| 637 |
+
f"",
|
| 638 |
+
"**Per-class F1:**",
|
| 639 |
+
"",
|
| 640 |
+
"| Emotion | Precision | Recall | F1 | Support |",
|
| 641 |
+
"|---|---|---|---|---|",
|
| 642 |
+
])
|
| 643 |
+
for label in EVAL_LABELS:
|
| 644 |
+
pc = cond["per_class"].get(label, {})
|
| 645 |
+
lines.append(
|
| 646 |
+
f"| {label} | {pc.get('precision', 0):.3f} "
|
| 647 |
+
f"| {pc.get('recall', 0):.3f} "
|
| 648 |
+
f"| {pc.get('f1', 0):.3f} "
|
| 649 |
+
f"| {pc.get('support', 0)} |"
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
# Confusion matrix
|
| 653 |
+
lines.extend(["", "**Confusion Matrix:**", ""])
|
| 654 |
+
cm = cond.get("confusion_matrix", [])
|
| 655 |
+
if cm:
|
| 656 |
+
lines.append("| | " + " | ".join(EVAL_LABELS) + " |")
|
| 657 |
+
lines.append("| --- | " + " | ".join(["---"] * len(EVAL_LABELS)) + " |")
|
| 658 |
+
for i, row in enumerate(cm):
|
| 659 |
+
lines.append(f"| **{EVAL_LABELS[i]}** | " + " | ".join(str(v) for v in row) + " |")
|
| 660 |
+
|
| 661 |
+
lines.append("")
|
| 662 |
+
|
| 663 |
+
# Limitations
|
| 664 |
+
lines.extend([
|
| 665 |
+
"## Known Limitations",
|
| 666 |
+
"",
|
| 667 |
+
"- **SpeechBrain wav2vec2-IEMOCAP**: Only outputs 4 classes (angry, happy, sad, neutral). "
|
| 668 |
+
"Cannot predict fear or surprise โ structurally penalized in 6-class macro F1.",
|
| 669 |
+
"- **Whisper-Medium + Head**: Requires a separately trained classifier head. "
|
| 670 |
+
"Without training, results reflect random baseline (~16.7%).",
|
| 671 |
+
"- **AI Hub dataset**: No 'disgust' class โ evaluated as 6-class instead of project's 7-class.",
|
| 672 |
+
"",
|
| 673 |
+
])
|
| 674 |
+
|
| 675 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 676 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 677 |
+
f.write("\n".join(lines))
|
| 678 |
+
logger.info("Markdown report saved to %s", output_path)
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 682 |
+
# Main
|
| 683 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 684 |
+
|
| 685 |
+
ADAPTER_MAP = {
|
| 686 |
+
"emotion2vec": Emotion2vecAdapter,
|
| 687 |
+
"speechbrain": SpeechBrainAdapter,
|
| 688 |
+
"whisper": WhisperMediumAdapter,
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def main():
|
| 693 |
+
parser = argparse.ArgumentParser(description="3-Model SER Benchmark")
|
| 694 |
+
parser.add_argument("--test-dir", required=True, help="ํ
์คํธ ์๋ธ์
๋๋ ํ ๋ฆฌ (test_labels.csv ํฌํจ)")
|
| 695 |
+
parser.add_argument("--models", nargs="+", default=["emotion2vec", "speechbrain"],
|
| 696 |
+
choices=list(ADAPTER_MAP.keys()), help="๋ฒค์น๋งํฌํ ๋ชจ๋ธ (default: emotion2vec speechbrain)")
|
| 697 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"], help="Compute device")
|
| 698 |
+
parser.add_argument("--whisper-head-ckpt", default=None, help="Whisper emotion head ์ฒดํฌํฌ์ธํธ ๊ฒฝ๋ก")
|
| 699 |
+
parser.add_argument("--phone-augment", action="store_true", default=False, help="Phone augmentation ํ๊ฐ ์ถ๊ฐ")
|
| 700 |
+
parser.add_argument("--warmup", type=int, default=5, help="Warmup ํ์")
|
| 701 |
+
parser.add_argument("--max-samples", type=int, default=None, help="์ต๋ ์ํ ์ (smoke test์ฉ)")
|
| 702 |
+
parser.add_argument("--output-json", default="data/evaluation/benchmark_3model_results.json")
|
| 703 |
+
parser.add_argument("--output-md", default="docs/stage2/benchmark-3model-report.md")
|
| 704 |
+
args = parser.parse_args()
|
| 705 |
+
|
| 706 |
+
# Load test data
|
| 707 |
+
samples = load_test_data(args.test_dir, max_samples=args.max_samples)
|
| 708 |
+
if not samples:
|
| 709 |
+
logger.error("No test samples loaded")
|
| 710 |
+
sys.exit(1)
|
| 711 |
+
|
| 712 |
+
# Run benchmarks
|
| 713 |
+
all_results = {}
|
| 714 |
+
for model_name in args.models:
|
| 715 |
+
adapter_cls = ADAPTER_MAP[model_name]
|
| 716 |
+
if model_name == "whisper":
|
| 717 |
+
adapter = adapter_cls(head_ckpt=args.whisper_head_ckpt)
|
| 718 |
+
else:
|
| 719 |
+
adapter = adapter_cls()
|
| 720 |
+
|
| 721 |
+
result = benchmark_model(
|
| 722 |
+
adapter, samples, args.device,
|
| 723 |
+
phone_augment=args.phone_augment,
|
| 724 |
+
warmup=args.warmup,
|
| 725 |
+
)
|
| 726 |
+
result["knockout"] = knockout_check(result)
|
| 727 |
+
all_results[adapter.name] = result
|
| 728 |
+
|
| 729 |
+
# Save JSON
|
| 730 |
+
output_json_path = Path(args.output_json)
|
| 731 |
+
output_json_path.parent.mkdir(parents=True, exist_ok=True)
|
| 732 |
+
|
| 733 |
+
import platform
|
| 734 |
+
try:
|
| 735 |
+
import torch
|
| 736 |
+
torch_version = torch.__version__
|
| 737 |
+
cuda_available = torch.cuda.is_available()
|
| 738 |
+
except ImportError:
|
| 739 |
+
torch_version = "not installed"
|
| 740 |
+
cuda_available = False
|
| 741 |
+
|
| 742 |
+
output_data = {
|
| 743 |
+
"metadata": {
|
| 744 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
| 745 |
+
"device": args.device,
|
| 746 |
+
"test_samples": len(samples),
|
| 747 |
+
"eval_classes": EVAL_LABELS,
|
| 748 |
+
"conditions": ["clean"] + (["phone"] if args.phone_augment else []),
|
| 749 |
+
"system_info": {
|
| 750 |
+
"cpu": platform.processor() or "unknown",
|
| 751 |
+
"ram_total_gb": round(psutil.virtual_memory().total / (1024**3), 1),
|
| 752 |
+
"python": platform.python_version(),
|
| 753 |
+
"torch": torch_version,
|
| 754 |
+
"cuda": cuda_available,
|
| 755 |
+
},
|
| 756 |
+
},
|
| 757 |
+
"results": all_results,
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
with open(output_json_path, "w", encoding="utf-8") as f:
|
| 761 |
+
json.dump(output_data, f, indent=2, ensure_ascii=False, default=str)
|
| 762 |
+
logger.info("JSON results saved to %s", output_json_path)
|
| 763 |
+
|
| 764 |
+
# Generate markdown report
|
| 765 |
+
generate_markdown_report(all_results, args.output_md)
|
| 766 |
+
|
| 767 |
+
# Console summary
|
| 768 |
+
print("\n" + "=" * 60)
|
| 769 |
+
print("BENCHMARK COMPLETE")
|
| 770 |
+
print("=" * 60)
|
| 771 |
+
for name, res in all_results.items():
|
| 772 |
+
if "error" in res:
|
| 773 |
+
print(f"\n {name}: LOAD FAILED โ {res['error']}")
|
| 774 |
+
continue
|
| 775 |
+
clean = res.get("clean", {})
|
| 776 |
+
ko = res.get("knockout", {}).get("clean", {})
|
| 777 |
+
print(f"\n {name} ({res['params_m']}M params):")
|
| 778 |
+
print(f" Clean F1: {clean.get('macro_f1', 0):.3f} Accuracy: {clean.get('accuracy', 0):.3f}")
|
| 779 |
+
print(f" Latency: {clean.get('latency', {}).get('mean_ms', 0):.0f}ms (mean), "
|
| 780 |
+
f"{clean.get('latency', {}).get('p95_ms', 0):.0f}ms (p95)")
|
| 781 |
+
print(f" RAM: {clean.get('peak_ram_mb', 0):.0f}MB")
|
| 782 |
+
print(f" Knockout: {ko.get('overall', 'N/A')}")
|
| 783 |
+
|
| 784 |
+
if args.phone_augment:
|
| 785 |
+
print("\n --- Phone Degradation ---")
|
| 786 |
+
for name, res in all_results.items():
|
| 787 |
+
if "error" in res or "phone" not in res:
|
| 788 |
+
continue
|
| 789 |
+
clean_f1 = res.get("clean", {}).get("macro_f1", 0)
|
| 790 |
+
phone_f1 = res["phone"]["macro_f1"]
|
| 791 |
+
drop = clean_f1 - phone_f1
|
| 792 |
+
print(f" {name}: {clean_f1:.3f} โ {phone_f1:.3f} (ฮ={drop:+.3f})")
|
| 793 |
+
|
| 794 |
+
print(f"\n Results: {args.output_json}")
|
| 795 |
+
print(f" Report: {args.output_md}")
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
if __name__ == "__main__":
|
| 799 |
+
main()
|
scripts/build_english_fusion_manifest.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Build unified English fusion evaluation manifest.
|
| 3 |
+
|
| 4 |
+
Combines:
|
| 5 |
+
- JL-Corpus 5-class (angryโanger, happyโjoy, sadโsadness, neutral, anxiousโfear)
|
| 6 |
+
- SAVEE 2-class (disgust, surprise) with WhisperX ASR transcripts
|
| 7 |
+
- MELD fusion disgust + surprise samples (natural dialogue)
|
| 8 |
+
|
| 9 |
+
Output: data/english_fusion/manifest.json
|
| 10 |
+
Format: [{"path": ..., "text": ..., "label": ..., "source": ..., "speaker": ...}, ...]
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
from collections import Counter
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 24 |
+
|
| 25 |
+
JL_MAP = {
|
| 26 |
+
"angry": "anger",
|
| 27 |
+
"happy": "joy",
|
| 28 |
+
"sad": "sadness",
|
| 29 |
+
"neutral": "neutral",
|
| 30 |
+
"anxious": "fear",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
SAVEE_MAP = {"d": "disgust", "su": "surprise"}
|
| 34 |
+
SAVEE_PATTERN = re.compile(r"^(?P<spk>[A-Z]{2})_(?P<emo>d|su)(?P<idx>\d+)\.wav$")
|
| 35 |
+
JL_PATTERN = re.compile(r"^(?P<spk>[a-z]+\d+)_(?P<emo>[a-z]+)_(?P<sid>[^_]+)_(?P<take>\d+)\.wav$")
|
| 36 |
+
|
| 37 |
+
OUT_DIR = Path("data/english_fusion")
|
| 38 |
+
OUT_MANIFEST = OUT_DIR / "manifest.json"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_jl_corpus() -> list[dict]:
|
| 42 |
+
jl_dir = Path("data/jl_corpus")
|
| 43 |
+
rows: list[dict] = []
|
| 44 |
+
for wav in sorted(jl_dir.glob("*.wav")):
|
| 45 |
+
m = JL_PATTERN.match(wav.name)
|
| 46 |
+
if not m:
|
| 47 |
+
continue
|
| 48 |
+
emo_jl = m.group("emo")
|
| 49 |
+
if emo_jl not in JL_MAP:
|
| 50 |
+
continue
|
| 51 |
+
label = JL_MAP[emo_jl]
|
| 52 |
+
txt_path = wav.with_suffix(".txt")
|
| 53 |
+
if not txt_path.exists():
|
| 54 |
+
continue
|
| 55 |
+
text = txt_path.read_text(encoding="utf-8", errors="ignore").strip()
|
| 56 |
+
if not text:
|
| 57 |
+
continue
|
| 58 |
+
rows.append({
|
| 59 |
+
"path": str(wav),
|
| 60 |
+
"text": text,
|
| 61 |
+
"label": label,
|
| 62 |
+
"source": "jl_corpus",
|
| 63 |
+
"speaker": m.group("spk"),
|
| 64 |
+
})
|
| 65 |
+
logger.info("JL-Corpus 5-class: %d samples", len(rows))
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_savee() -> list[dict]:
|
| 70 |
+
savee_dir = Path("data/savee/ALL")
|
| 71 |
+
asr_path = Path("data/savee/savee_asr.json")
|
| 72 |
+
if not asr_path.exists():
|
| 73 |
+
logger.warning("SAVEE ASR not found at %s", asr_path)
|
| 74 |
+
return []
|
| 75 |
+
asr = json.loads(asr_path.read_text())
|
| 76 |
+
|
| 77 |
+
rows: list[dict] = []
|
| 78 |
+
for wav in sorted(savee_dir.iterdir()):
|
| 79 |
+
m = SAVEE_PATTERN.match(wav.name)
|
| 80 |
+
if not m:
|
| 81 |
+
continue
|
| 82 |
+
label = SAVEE_MAP[m.group("emo")]
|
| 83 |
+
text = asr.get(wav.name, "").strip()
|
| 84 |
+
if not text:
|
| 85 |
+
continue
|
| 86 |
+
rows.append({
|
| 87 |
+
"path": str(wav),
|
| 88 |
+
"text": text,
|
| 89 |
+
"label": label,
|
| 90 |
+
"source": "savee",
|
| 91 |
+
"speaker": m.group("spk"),
|
| 92 |
+
})
|
| 93 |
+
logger.info("SAVEE disgust+surprise: %d samples", len(rows))
|
| 94 |
+
return rows
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_meld_disgust_surprise() -> list[dict]:
|
| 98 |
+
meld_path = Path("data/meld_fusion/manifest.json")
|
| 99 |
+
if not meld_path.exists():
|
| 100 |
+
logger.warning("MELD manifest missing: %s", meld_path)
|
| 101 |
+
return []
|
| 102 |
+
data = json.loads(meld_path.read_text())
|
| 103 |
+
rows: list[dict] = []
|
| 104 |
+
for d in data:
|
| 105 |
+
if d["label"] not in ("disgust", "surprise"):
|
| 106 |
+
continue
|
| 107 |
+
if not d.get("text", "").strip():
|
| 108 |
+
continue
|
| 109 |
+
rows.append({
|
| 110 |
+
"path": d["path"],
|
| 111 |
+
"text": d["text"],
|
| 112 |
+
"label": d["label"],
|
| 113 |
+
"source": "meld",
|
| 114 |
+
"speaker": f"dia{d.get('dialogue_id', '?')}",
|
| 115 |
+
})
|
| 116 |
+
logger.info("MELD disgust+surprise: %d samples", len(rows))
|
| 117 |
+
return rows
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def main() -> None:
|
| 121 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
all_rows = load_jl_corpus() + load_savee() + load_meld_disgust_surprise()
|
| 123 |
+
|
| 124 |
+
counts = Counter(r["label"] for r in all_rows)
|
| 125 |
+
sources = Counter(r["source"] for r in all_rows)
|
| 126 |
+
logger.info("Total: %d samples", len(all_rows))
|
| 127 |
+
logger.info("By label: %s", dict(counts))
|
| 128 |
+
logger.info("By source: %s", dict(sources))
|
| 129 |
+
|
| 130 |
+
OUT_MANIFEST.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False))
|
| 131 |
+
logger.info("Saved to %s", OUT_MANIFEST)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
scripts/build_meld_test_sets.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Build English test sets from MELD (Friends) dataset.
|
| 4 |
+
|
| 5 |
+
Extracts 8 scenario-based test sets from MELD MP4 clips,
|
| 6 |
+
converts to WAV, and concatenates into single audio files
|
| 7 |
+
that simulate real phone calls for E2E pipeline testing.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/build_meld_test_sets.py
|
| 11 |
+
|
| 12 |
+
Output:
|
| 13 |
+
data/meld_test/
|
| 14 |
+
โโโ 01_angry_fight.wav
|
| 15 |
+
โโโ 02_happy_loving.wav
|
| 16 |
+
โโโ ...
|
| 17 |
+
โโโ 08_calm_daily.wav
|
| 18 |
+
โโโ ground_truth.json # per-utterance emotion labels
|
| 19 |
+
โโโ README.md # test set descriptions
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import csv
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import subprocess
|
| 26 |
+
import sys
|
| 27 |
+
import tempfile
|
| 28 |
+
from collections import Counter
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
# --- Configuration ---
|
| 32 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 33 |
+
ZIP_PATH = PROJECT_ROOT / "data" / "english_test.zip"
|
| 34 |
+
OUTPUT_DIR = PROJECT_ROOT / "data" / "meld_test"
|
| 35 |
+
SAMPLE_RATE = 16000 # 16kHz mono โ matches our pipeline input
|
| 36 |
+
|
| 37 |
+
# 8 test scenarios โ each maps to a specific MELD dialogue
|
| 38 |
+
TEST_SETS = [
|
| 39 |
+
{
|
| 40 |
+
"tag": "01_angry_fight",
|
| 41 |
+
"desc": "Ross-Rachel breakup fight โ anger dominant (S3E15)",
|
| 42 |
+
"scenario": "Couple in a heated argument",
|
| 43 |
+
"primary_emotion": "anger",
|
| 44 |
+
"split": "train",
|
| 45 |
+
"dia_id": "51",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"tag": "02_happy_loving",
|
| 49 |
+
"desc": "Monica-Chandler sweet moment โ joy dominant (S5E14)",
|
| 50 |
+
"scenario": "Couple being affectionate and playful",
|
| 51 |
+
"primary_emotion": "joy",
|
| 52 |
+
"split": "train",
|
| 53 |
+
"dia_id": "1026",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"tag": "03_sad_emotional",
|
| 57 |
+
"desc": "Ross-Rachel emotional confession โ sadness dominant (S3E25)",
|
| 58 |
+
"scenario": "Emotional conversation with sadness and regret",
|
| 59 |
+
"primary_emotion": "sadness",
|
| 60 |
+
"split": "train",
|
| 61 |
+
"dia_id": "312",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"tag": "04_surprise_shock",
|
| 65 |
+
"desc": "Ross-Rachel surprise revelations (S7E18)",
|
| 66 |
+
"scenario": "Unexpected news and reactions",
|
| 67 |
+
"primary_emotion": "surprise",
|
| 68 |
+
"split": "train",
|
| 69 |
+
"dia_id": "747",
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"tag": "05_fear_anxiety",
|
| 73 |
+
"desc": "Monica-Chandler anxious situation โ fear+mixed (S4E14)",
|
| 74 |
+
"scenario": "Anxious and worried conversation",
|
| 75 |
+
"primary_emotion": "fear",
|
| 76 |
+
"split": "train",
|
| 77 |
+
"dia_id": "109",
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"tag": "06_disgust_annoyance",
|
| 81 |
+
"desc": "Family annoyance scene โ disgust+anger (S6E9)",
|
| 82 |
+
"scenario": "Annoyed and disgusted reactions",
|
| 83 |
+
"primary_emotion": "disgust",
|
| 84 |
+
"split": "train",
|
| 85 |
+
"dia_id": "1025",
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"tag": "07_bittersweet",
|
| 89 |
+
"desc": "Ross-Rachel bittersweet farewell โ sadness+surprise (S5E5)",
|
| 90 |
+
"scenario": "Mixed emotions: saying goodbye with conflicting feelings",
|
| 91 |
+
"primary_emotion": "sadness",
|
| 92 |
+
"split": "train",
|
| 93 |
+
"dia_id": "676",
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"tag": "08_calm_daily",
|
| 97 |
+
"desc": "Casual daily conversation โ neutral baseline (S3E23)",
|
| 98 |
+
"scenario": "Normal everyday chitchat (baseline)",
|
| 99 |
+
"primary_emotion": "neutral",
|
| 100 |
+
"split": "train",
|
| 101 |
+
"dia_id": "450",
|
| 102 |
+
},
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def load_csv_from_zip(zip_path: Path) -> dict[str, list[dict]]:
|
| 107 |
+
"""Load all CSV data from zip, grouped by split_diaID."""
|
| 108 |
+
import zipfile
|
| 109 |
+
|
| 110 |
+
dialogues = {}
|
| 111 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 112 |
+
csv_files = [
|
| 113 |
+
("train", "JSON files/JSON files/CSV Processed/train_sent_emo_cleaned_processed.csv"),
|
| 114 |
+
("dev", "JSON files/JSON files/CSV Processed/dev_sent_emo_cleaned_processed.csv"),
|
| 115 |
+
("test", "JSON files/JSON files/CSV Processed/test_sent_emo_cleaned_processed.csv"),
|
| 116 |
+
]
|
| 117 |
+
for split, csv_path in csv_files:
|
| 118 |
+
try:
|
| 119 |
+
with zf.open(csv_path) as f:
|
| 120 |
+
import io
|
| 121 |
+
reader = csv.DictReader(io.TextIOWrapper(f, encoding="utf-8"))
|
| 122 |
+
for row in reader:
|
| 123 |
+
key = f"{split}_{row['Dialogue_ID']}"
|
| 124 |
+
dialogues.setdefault(key, []).append(row)
|
| 125 |
+
except KeyError:
|
| 126 |
+
print(f" Warning: {csv_path} not found in zip")
|
| 127 |
+
return dialogues
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def find_mp4_path(split: str, dia_id: str, utt_id: str, available_files: set) -> str | None:
|
| 131 |
+
"""Find MP4 file path for a specific utterance."""
|
| 132 |
+
patterns = [
|
| 133 |
+
f"MELD.Raw/MELD.Raw/{split}/{split}_splits/dia{dia_id}_utt{utt_id}.mp4",
|
| 134 |
+
f"MELD.Raw/MELD.Raw/{split}/{split}_splits_complete/dia{dia_id}_utt{utt_id}.mp4",
|
| 135 |
+
f"MELD.Raw/MELD.Raw/{split}/output_repeated_splits_{split}/final_videos_{split}dia{dia_id}_utt{utt_id}.mp4",
|
| 136 |
+
]
|
| 137 |
+
for p in patterns:
|
| 138 |
+
if p in available_files:
|
| 139 |
+
return p
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_mp4_list_from_zip(zip_path: Path) -> set:
|
| 144 |
+
"""Get set of all MP4 file paths in zip."""
|
| 145 |
+
import zipfile
|
| 146 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 147 |
+
return {n for n in zf.namelist() if n.endswith(".mp4")}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def extract_and_concat_wav(
|
| 151 |
+
zip_path: Path, mp4_paths: list[str], output_wav: Path, sample_rate: int = 16000
|
| 152 |
+
) -> float:
|
| 153 |
+
"""Extract audio from MP4s in zip and concatenate into single WAV."""
|
| 154 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 155 |
+
tmpdir = Path(tmpdir)
|
| 156 |
+
wav_parts = []
|
| 157 |
+
|
| 158 |
+
# Extract each MP4 and convert to WAV
|
| 159 |
+
import zipfile
|
| 160 |
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
| 161 |
+
for i, mp4_path in enumerate(mp4_paths):
|
| 162 |
+
mp4_local = tmpdir / f"part_{i:03d}.mp4"
|
| 163 |
+
wav_local = tmpdir / f"part_{i:03d}.wav"
|
| 164 |
+
|
| 165 |
+
# Extract MP4
|
| 166 |
+
with zf.open(mp4_path) as src, open(mp4_local, "wb") as dst:
|
| 167 |
+
dst.write(src.read())
|
| 168 |
+
|
| 169 |
+
# Convert to WAV (16kHz mono)
|
| 170 |
+
result = subprocess.run(
|
| 171 |
+
[
|
| 172 |
+
"ffmpeg", "-y", "-i", str(mp4_local),
|
| 173 |
+
"-ar", str(sample_rate),
|
| 174 |
+
"-ac", "1",
|
| 175 |
+
"-acodec", "pcm_s16le",
|
| 176 |
+
str(wav_local),
|
| 177 |
+
],
|
| 178 |
+
capture_output=True,
|
| 179 |
+
text=True,
|
| 180 |
+
)
|
| 181 |
+
if result.returncode != 0:
|
| 182 |
+
print(f" Warning: ffmpeg failed for {mp4_path}: {result.stderr[:200]}")
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
if wav_local.exists() and wav_local.stat().st_size > 0:
|
| 186 |
+
wav_parts.append(wav_local)
|
| 187 |
+
|
| 188 |
+
if not wav_parts:
|
| 189 |
+
return 0.0
|
| 190 |
+
|
| 191 |
+
# Concatenate WAVs using ffmpeg concat
|
| 192 |
+
list_file = tmpdir / "concat_list.txt"
|
| 193 |
+
with open(list_file, "w") as f:
|
| 194 |
+
for wp in wav_parts:
|
| 195 |
+
f.write(f"file '{wp}'\n")
|
| 196 |
+
|
| 197 |
+
output_wav.parent.mkdir(parents=True, exist_ok=True)
|
| 198 |
+
result = subprocess.run(
|
| 199 |
+
[
|
| 200 |
+
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
| 201 |
+
"-i", str(list_file),
|
| 202 |
+
"-ar", str(sample_rate),
|
| 203 |
+
"-ac", "1",
|
| 204 |
+
"-acodec", "pcm_s16le",
|
| 205 |
+
str(output_wav),
|
| 206 |
+
],
|
| 207 |
+
capture_output=True,
|
| 208 |
+
text=True,
|
| 209 |
+
)
|
| 210 |
+
if result.returncode != 0:
|
| 211 |
+
print(f" Concat failed: {result.stderr[:300]}")
|
| 212 |
+
return 0.0
|
| 213 |
+
|
| 214 |
+
# Get duration
|
| 215 |
+
probe = subprocess.run(
|
| 216 |
+
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
| 217 |
+
"-of", "default=noprint_wrappers=1:nokey=1", str(output_wav)],
|
| 218 |
+
capture_output=True, text=True,
|
| 219 |
+
)
|
| 220 |
+
try:
|
| 221 |
+
return float(probe.stdout.strip())
|
| 222 |
+
except ValueError:
|
| 223 |
+
return 0.0
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def main():
|
| 227 |
+
print("=" * 60)
|
| 228 |
+
print(" MELD English Test Set Builder")
|
| 229 |
+
print("=" * 60)
|
| 230 |
+
|
| 231 |
+
if not ZIP_PATH.exists():
|
| 232 |
+
print(f"Error: {ZIP_PATH} not found")
|
| 233 |
+
sys.exit(1)
|
| 234 |
+
|
| 235 |
+
# 1. Load CSV data
|
| 236 |
+
print("\n[1/4] Loading CSV data from zip...")
|
| 237 |
+
dialogues = load_csv_from_zip(ZIP_PATH)
|
| 238 |
+
print(f" Loaded {len(dialogues)} dialogues")
|
| 239 |
+
|
| 240 |
+
# 2. Get available MP4 files
|
| 241 |
+
print("[2/4] Scanning MP4 files in zip...")
|
| 242 |
+
mp4_files = get_mp4_list_from_zip(ZIP_PATH)
|
| 243 |
+
print(f" Found {len(mp4_files)} MP4 files")
|
| 244 |
+
|
| 245 |
+
# 3. Process each test set
|
| 246 |
+
print("[3/4] Building test sets...\n")
|
| 247 |
+
ground_truth = {}
|
| 248 |
+
summary_lines = []
|
| 249 |
+
|
| 250 |
+
for ts in TEST_SETS:
|
| 251 |
+
tag = ts["tag"]
|
| 252 |
+
key = f"{ts['split']}_{ts['dia_id']}"
|
| 253 |
+
utts = dialogues.get(key, [])
|
| 254 |
+
|
| 255 |
+
if not utts:
|
| 256 |
+
print(f" โ {tag}: dialogue {key} not found")
|
| 257 |
+
continue
|
| 258 |
+
|
| 259 |
+
print(f" ๐ฆ {tag} โ {ts['desc']}")
|
| 260 |
+
print(f" {len(utts)} utterances", end="")
|
| 261 |
+
|
| 262 |
+
# Find MP4 paths
|
| 263 |
+
mp4_paths = []
|
| 264 |
+
for u in utts:
|
| 265 |
+
p = find_mp4_path(ts["split"], ts["dia_id"], u["Utterance_ID"], mp4_files)
|
| 266 |
+
if p:
|
| 267 |
+
mp4_paths.append(p)
|
| 268 |
+
|
| 269 |
+
print(f", {len(mp4_paths)}/{len(utts)} MP4s found")
|
| 270 |
+
|
| 271 |
+
if not mp4_paths:
|
| 272 |
+
print(f" โ No MP4 files found, skipping")
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
# Extract and concatenate
|
| 276 |
+
output_wav = OUTPUT_DIR / f"{tag}.wav"
|
| 277 |
+
duration = extract_and_concat_wav(ZIP_PATH, mp4_paths, output_wav, SAMPLE_RATE)
|
| 278 |
+
print(f" โ
{output_wav.name} โ {duration:.1f}s")
|
| 279 |
+
|
| 280 |
+
# Build ground truth
|
| 281 |
+
emo_counts = Counter(u["Emotion"] for u in utts)
|
| 282 |
+
ground_truth[tag] = {
|
| 283 |
+
"description": ts["desc"],
|
| 284 |
+
"scenario": ts["scenario"],
|
| 285 |
+
"primary_emotion": ts["primary_emotion"],
|
| 286 |
+
"source": f"MELD Friends S{utts[0]['Season']}E{utts[0]['Episode']} Dialogue {ts['dia_id']}",
|
| 287 |
+
"duration_sec": round(duration, 1),
|
| 288 |
+
"emotion_distribution": dict(emo_counts),
|
| 289 |
+
"total_utterances": len(utts),
|
| 290 |
+
"utterances": [
|
| 291 |
+
{
|
| 292 |
+
"speaker": u["Speaker"],
|
| 293 |
+
"emotion": u["Emotion"],
|
| 294 |
+
"sentiment": u["Sentiment"],
|
| 295 |
+
"text": u["Utterance"],
|
| 296 |
+
}
|
| 297 |
+
for u in utts
|
| 298 |
+
],
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
summary_lines.append(
|
| 302 |
+
f"| {tag} | {ts['scenario'][:40]} | {ts['primary_emotion']} | {duration:.1f}s | {len(utts)} utts | {dict(emo_counts)} |"
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# 4. Save ground truth + README
|
| 306 |
+
print("\n[4/4] Saving metadata...")
|
| 307 |
+
|
| 308 |
+
gt_path = OUTPUT_DIR / "ground_truth.json"
|
| 309 |
+
with open(gt_path, "w", encoding="utf-8") as f:
|
| 310 |
+
json.dump(ground_truth, f, indent=2, ensure_ascii=False)
|
| 311 |
+
print(f" โ
{gt_path}")
|
| 312 |
+
|
| 313 |
+
# Emotion alignment check
|
| 314 |
+
our_labels = {"neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"}
|
| 315 |
+
meld_labels = set()
|
| 316 |
+
for gt in ground_truth.values():
|
| 317 |
+
meld_labels.update(gt["emotion_distribution"].keys())
|
| 318 |
+
|
| 319 |
+
readme_content = f"""# MELD English Test Sets
|
| 320 |
+
|
| 321 |
+
## Emotion Label Alignment
|
| 322 |
+
|
| 323 |
+
| UsTwo Pipeline (EN) | MELD Label | Match |
|
| 324 |
+
|---|---|---|
|
| 325 |
+
| neutral | neutral | โ
Exact |
|
| 326 |
+
| joy | joy | โ
Exact |
|
| 327 |
+
| sadness | sadness | โ
Exact |
|
| 328 |
+
| anger | anger | โ
Exact |
|
| 329 |
+
| surprise | surprise | โ
Exact |
|
| 330 |
+
| fear | fear | โ
Exact |
|
| 331 |
+
| disgust | disgust | โ
Exact |
|
| 332 |
+
|
| 333 |
+
**7/7 labels match exactly.** No mapping needed.
|
| 334 |
+
|
| 335 |
+
## Test Sets
|
| 336 |
+
|
| 337 |
+
| File | Scenario | Primary Emotion | Duration | Utterances | Emotion Distribution |
|
| 338 |
+
|---|---|---|---|---|---|
|
| 339 |
+
{chr(10).join(summary_lines)}
|
| 340 |
+
|
| 341 |
+
## Source
|
| 342 |
+
- Dataset: MELD (Multimodal EmotionLines Dataset)
|
| 343 |
+
- Source: Friends TV series
|
| 344 |
+
- Paper: Poria et al., ACL 2019
|
| 345 |
+
- Each WAV is a full dialogue concatenated from per-utterance MP4 clips
|
| 346 |
+
- Audio: 16kHz mono PCM (matches pipeline input format)
|
| 347 |
+
|
| 348 |
+
## Usage
|
| 349 |
+
```bash
|
| 350 |
+
# Run pipeline on a single test set
|
| 351 |
+
python scripts/run_pipeline.py data/meld_test/01_angry_fight.wav
|
| 352 |
+
|
| 353 |
+
# Evaluate all test sets
|
| 354 |
+
python scripts/evaluate_meld_test.py
|
| 355 |
+
```
|
| 356 |
+
"""
|
| 357 |
+
readme_path = OUTPUT_DIR / "README.md"
|
| 358 |
+
with open(readme_path, "w", encoding="utf-8") as f:
|
| 359 |
+
f.write(readme_content)
|
| 360 |
+
print(f" โ
{readme_path}")
|
| 361 |
+
|
| 362 |
+
# Summary
|
| 363 |
+
print("\n" + "=" * 60)
|
| 364 |
+
print(" DONE")
|
| 365 |
+
print("=" * 60)
|
| 366 |
+
total_files = len(list(OUTPUT_DIR.glob("*.wav")))
|
| 367 |
+
print(f" {total_files} WAV files in {OUTPUT_DIR}")
|
| 368 |
+
print(f" Ground truth: {gt_path}")
|
| 369 |
+
print(f" Emotion alignment: {len(our_labels & meld_labels)}/{len(our_labels)} exact match")
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
if __name__ == "__main__":
|
| 373 |
+
main()
|
scripts/cache_models.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Pre-download and cache all ML models at Docker build time.
|
| 3 |
+
|
| 4 |
+
This avoids cold-start model downloads on first request in HF Spaces.
|
| 5 |
+
Models are cached to default HuggingFace/torch hub directories.
|
| 6 |
+
|
| 7 |
+
Usage (in Dockerfile):
|
| 8 |
+
RUN python scripts/cache_models.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
| 16 |
+
logger = logging.getLogger("cache_models")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def cache_pyannote():
|
| 20 |
+
"""Cache pyannote speaker-diarization-3.1 (~1.5GB)."""
|
| 21 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 22 |
+
if not hf_token:
|
| 23 |
+
logger.warning("HF_TOKEN not set, skipping pyannote cache")
|
| 24 |
+
return
|
| 25 |
+
try:
|
| 26 |
+
from pyannote.audio import Pipeline
|
| 27 |
+
logger.info("Caching pyannote/speaker-diarization-3.1...")
|
| 28 |
+
Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", token=hf_token)
|
| 29 |
+
logger.info("pyannote cached OK")
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.warning("pyannote cache failed: %s", e)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def cache_whisperx():
|
| 35 |
+
"""Cache WhisperX large-v3-turbo INT8 (~1.5GB)."""
|
| 36 |
+
try:
|
| 37 |
+
import whisperx
|
| 38 |
+
logger.info("Caching whisperx large-v3-turbo (int8)...")
|
| 39 |
+
whisperx.load_model("large-v3-turbo", device="cpu", compute_type="int8")
|
| 40 |
+
logger.info("whisperx cached OK")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.warning("whisperx cache failed: %s", e)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cache_emotion2vec():
|
| 46 |
+
"""Cache emotion2vec_plus_base (~300MB)."""
|
| 47 |
+
try:
|
| 48 |
+
from funasr import AutoModel
|
| 49 |
+
logger.info("Caching iic/emotion2vec_plus_base...")
|
| 50 |
+
AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
|
| 51 |
+
logger.info("emotion2vec cached OK")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.warning("emotion2vec cache failed: %s", e)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def cache_text_models():
|
| 57 |
+
"""Cache text emotion models (~300MB each)."""
|
| 58 |
+
try:
|
| 59 |
+
from transformers import pipeline
|
| 60 |
+
logger.info("Caching j-hartmann/emotion-english-distilroberta-base...")
|
| 61 |
+
pipeline("text-classification", model="j-hartmann/emotion-english-distilroberta-base", top_k=None)
|
| 62 |
+
logger.info("DistilRoBERTa cached OK")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.warning("DistilRoBERTa cache failed: %s", e)
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
from transformers import pipeline
|
| 68 |
+
logger.info("Caching searle-j/kote_for_easygoing_people...")
|
| 69 |
+
pipeline("text-classification", model="searle-j/kote_for_easygoing_people", top_k=None)
|
| 70 |
+
logger.info("KcELECTRA cached OK")
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.warning("KcELECTRA cache failed: %s", e)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def download_lora_onnx_models():
|
| 76 |
+
"""Download LoRA ONNX models from public HF Hub repo into data/models/."""
|
| 77 |
+
from pathlib import Path
|
| 78 |
+
from huggingface_hub import hf_hub_download, snapshot_download
|
| 79 |
+
|
| 80 |
+
repo_id = "BBBAKERY/ustwo-lora-models"
|
| 81 |
+
|
| 82 |
+
# emotion2vec ONNX โ data/models/lora_emotion2vec_7class/model.onnx
|
| 83 |
+
audio_dir = Path("data/models/lora_emotion2vec_7class")
|
| 84 |
+
audio_dir.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
try:
|
| 86 |
+
logger.info("Downloading emotion2vec LoRA ONNX from %s...", repo_id)
|
| 87 |
+
for fname in ["emotion2vec/model.onnx", "emotion2vec/model.json"]:
|
| 88 |
+
path = hf_hub_download(repo_id=repo_id, filename=fname, repo_type="model")
|
| 89 |
+
target = audio_dir / Path(fname).name
|
| 90 |
+
if not target.exists() or target.resolve() != Path(path).resolve():
|
| 91 |
+
import shutil
|
| 92 |
+
shutil.copy(path, target)
|
| 93 |
+
logger.info("emotion2vec LoRA ONNX cached OK")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.warning("emotion2vec LoRA ONNX download failed: %s", e)
|
| 96 |
+
|
| 97 |
+
# KcELECTRA ONNX + tokenizer โ data/models/lora_kcelectra_7class/
|
| 98 |
+
text_dir = Path("data/models/lora_kcelectra_7class")
|
| 99 |
+
text_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
try:
|
| 101 |
+
logger.info("Downloading KcELECTRA LoRA ONNX from %s...", repo_id)
|
| 102 |
+
for fname in ["kcelectra/model.onnx", "kcelectra/model.json"]:
|
| 103 |
+
path = hf_hub_download(repo_id=repo_id, filename=fname, repo_type="model")
|
| 104 |
+
target = text_dir / Path(fname).name
|
| 105 |
+
import shutil
|
| 106 |
+
shutil.copy(path, target)
|
| 107 |
+
|
| 108 |
+
# Tokenizer folder
|
| 109 |
+
tokenizer_target = text_dir / "best_model"
|
| 110 |
+
tokenizer_target.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
for fname in ["tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.txt"]:
|
| 112 |
+
path = hf_hub_download(repo_id=repo_id, filename=f"kcelectra/tokenizer/{fname}", repo_type="model")
|
| 113 |
+
import shutil
|
| 114 |
+
shutil.copy(path, tokenizer_target / fname)
|
| 115 |
+
|
| 116 |
+
logger.info("KcELECTRA LoRA ONNX + tokenizer cached OK")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.warning("KcELECTRA LoRA ONNX download failed: %s", e)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
logger.info("=== Pre-caching ML models ===")
|
| 123 |
+
cache_pyannote()
|
| 124 |
+
cache_whisperx()
|
| 125 |
+
cache_emotion2vec()
|
| 126 |
+
cache_text_models()
|
| 127 |
+
download_lora_onnx_models()
|
| 128 |
+
logger.info("=== Model caching complete ===")
|
scripts/convert_to_onnx.py
ADDED
|
File without changes
|
scripts/eval_audio_on_subset.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate audio model (LoRA ONNX or base) on a subset of val manifest.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/eval_audio_on_subset.py \
|
| 6 |
+
--val-manifest data/lora_dataset/val_manifest.json \
|
| 7 |
+
--source ravdess \
|
| 8 |
+
--model lora_onnx \
|
| 9 |
+
--onnx data/models/lora_emotion2vec_7class/model.onnx
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
from collections import Counter
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 25 |
+
|
| 26 |
+
LORA_LABELS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 27 |
+
LORA_TO_PROJECT = {
|
| 28 |
+
"happiness": "joy", "anger": "anger", "disgust": "disgust",
|
| 29 |
+
"fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
BASE_LABEL_MAP = {
|
| 33 |
+
"angry": "anger", "disgusted": "disgust", "fearful": "fear",
|
| 34 |
+
"happy": "joy", "neutral": "neutral", "sad": "sadness", "surprised": "surprise",
|
| 35 |
+
"other": "neutral", "unknown": "neutral",
|
| 36 |
+
"็ๆฐ/angry": "anger", "ๅๆถ/disgusted": "disgust", "ๆๆง/fearful": "fear",
|
| 37 |
+
"ๅผๅฟ/happy": "joy", "ไธญ็ซ/neutral": "neutral", "้พ่ฟ/sad": "sadness",
|
| 38 |
+
"ๅๆ/surprised": "surprise", "ๅ
ถไป/other": "neutral", "<unk>": "neutral",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def predict_lora_onnx(audio_path: str, session, max_seconds: float = 15.0):
|
| 43 |
+
import soundfile as sf
|
| 44 |
+
audio, sr = sf.read(audio_path, dtype="float32")
|
| 45 |
+
if audio.ndim == 2:
|
| 46 |
+
audio = audio.mean(axis=1)
|
| 47 |
+
if sr != 16000:
|
| 48 |
+
import librosa
|
| 49 |
+
audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
|
| 50 |
+
max_samples = int(max_seconds * 16000)
|
| 51 |
+
if len(audio) > max_samples:
|
| 52 |
+
audio = audio[:max_samples]
|
| 53 |
+
waveform = audio.reshape(1, -1).astype(np.float32)
|
| 54 |
+
logits = session.run(None, {"waveform": waveform})[0]
|
| 55 |
+
exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
| 56 |
+
probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
|
| 57 |
+
scores = {label: 0.0 for label in PROJECT_LABELS}
|
| 58 |
+
for lora_label, prob in zip(LORA_LABELS, probs):
|
| 59 |
+
scores[LORA_TO_PROJECT[lora_label]] = float(prob)
|
| 60 |
+
return max(scores, key=scores.get)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def predict_base(audio_path: str, funasr_model):
|
| 64 |
+
try:
|
| 65 |
+
output = funasr_model.generate(audio_path, granularity="utterance", extract_embedding=False)
|
| 66 |
+
except Exception:
|
| 67 |
+
return "neutral"
|
| 68 |
+
scores = {label: 0.0 for label in PROJECT_LABELS}
|
| 69 |
+
if output and isinstance(output, list) and len(output) > 0:
|
| 70 |
+
rec = output[0]
|
| 71 |
+
for native_label, score in zip(rec.get("labels", []), rec.get("scores", [])):
|
| 72 |
+
pl = BASE_LABEL_MAP.get(native_label, "neutral")
|
| 73 |
+
scores[pl] += float(score)
|
| 74 |
+
return max(scores, key=scores.get)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def f1_score(y_true, y_pred, label):
|
| 78 |
+
tp = sum(1 for t, p in zip(y_true, y_pred) if t == label and p == label)
|
| 79 |
+
fp = sum(1 for t, p in zip(y_true, y_pred) if t != label and p == label)
|
| 80 |
+
fn = sum(1 for t, p in zip(y_true, y_pred) if t == label and p != label)
|
| 81 |
+
if tp + fp == 0 or tp + fn == 0:
|
| 82 |
+
return 0.0
|
| 83 |
+
p = tp / (tp + fp); r = tp / (tp + fn)
|
| 84 |
+
return 2 * p * r / (p + r) if (p + r) > 0 else 0.0
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main():
|
| 88 |
+
parser = argparse.ArgumentParser()
|
| 89 |
+
parser.add_argument("--val-manifest", default="data/lora_dataset/val_manifest.json")
|
| 90 |
+
parser.add_argument("--source", default="ravdess", help="Filter by source: ravdess, 263, 71631")
|
| 91 |
+
parser.add_argument("--model", choices=["lora_onnx", "base"], default="lora_onnx")
|
| 92 |
+
parser.add_argument("--onnx", default="data/models/lora_emotion2vec_7class/model.onnx")
|
| 93 |
+
args = parser.parse_args()
|
| 94 |
+
|
| 95 |
+
with open(args.val_manifest) as f:
|
| 96 |
+
val = json.load(f)
|
| 97 |
+
samples = [s for s in val if s["source"] == args.source]
|
| 98 |
+
logger.info("Evaluating %s on %d %s samples", args.model, len(samples), args.source)
|
| 99 |
+
|
| 100 |
+
# Normalize labels: happiness โ joy
|
| 101 |
+
for s in samples:
|
| 102 |
+
if s["label"] == "happiness":
|
| 103 |
+
s["label"] = "joy"
|
| 104 |
+
|
| 105 |
+
if args.model == "lora_onnx":
|
| 106 |
+
import onnxruntime as ort
|
| 107 |
+
session = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
|
| 108 |
+
predict_fn = lambda p: predict_lora_onnx(p, session)
|
| 109 |
+
else:
|
| 110 |
+
from funasr import AutoModel
|
| 111 |
+
model = AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
|
| 112 |
+
predict_fn = lambda p: predict_base(p, model)
|
| 113 |
+
|
| 114 |
+
y_true, y_pred = [], []
|
| 115 |
+
for i, s in enumerate(samples):
|
| 116 |
+
y_true.append(s["label"])
|
| 117 |
+
y_pred.append(predict_fn(s["path"]))
|
| 118 |
+
if (i + 1) % 100 == 0:
|
| 119 |
+
logger.info("Progress: %d / %d", i + 1, len(samples))
|
| 120 |
+
|
| 121 |
+
# Per-class F1
|
| 122 |
+
f1s = {label: f1_score(y_true, y_pred, label) for label in PROJECT_LABELS}
|
| 123 |
+
macro_f1 = np.mean(list(f1s.values()))
|
| 124 |
+
acc = sum(1 for t, p in zip(y_true, y_pred) if t == p) / len(samples)
|
| 125 |
+
|
| 126 |
+
print()
|
| 127 |
+
print(f"=== {args.model} on {args.source} ({len(samples)} samples) ===")
|
| 128 |
+
print(f"Macro F1: {macro_f1:.4f}")
|
| 129 |
+
print(f"Accuracy: {acc:.4f}")
|
| 130 |
+
print("Per-class F1:")
|
| 131 |
+
for label, f1 in f1s.items():
|
| 132 |
+
support = sum(1 for t in y_true if t == label)
|
| 133 |
+
if support > 0:
|
| 134 |
+
print(f" {label:<12} {f1:.4f} (n={support})")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
main()
|
scripts/evaluate_emotion2vec_english.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""emotion2vec base ๋ชจ๋ธ ์์ด ํ๊ฐ (RAVDESS ๋ฐ์ดํฐ์
).
|
| 3 |
+
|
| 4 |
+
clean + phone 2๊ฐ ์กฐ๊ฑด์ผ๋ก ํ๊ฐํ์ฌ ์ค์ ํตํ ํ๊ฒฝ ์ฑ๋ฅ์ ์ถ์ ํ๋ค.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/evaluate_emotion2vec_english.py
|
| 8 |
+
python scripts/evaluate_emotion2vec_english.py --condition clean # clean๋ง
|
| 9 |
+
python scripts/evaluate_emotion2vec_english.py --condition phone # phone๋ง
|
| 10 |
+
python scripts/evaluate_emotion2vec_english.py --max-samples 100 # ๋น ๋ฅธ ํ
์คํธ
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import csv
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 24 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 25 |
+
|
| 26 |
+
logging.basicConfig(
|
| 27 |
+
level=logging.INFO,
|
| 28 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 29 |
+
)
|
| 30 |
+
logger = logging.getLogger("eval_emotion2vec_en")
|
| 31 |
+
|
| 32 |
+
MANIFEST_PATH = PROJECT_ROOT / "data" / "ravdess" / "manifest.csv"
|
| 33 |
+
OUTPUT_JSON = PROJECT_ROOT / "data" / "ravdess_eval_results.json"
|
| 34 |
+
|
| 35 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_manifest(max_samples: int | None = None) -> list[dict]:
|
| 39 |
+
"""manifest.csv ๋ก๋."""
|
| 40 |
+
rows = []
|
| 41 |
+
with open(MANIFEST_PATH) as f:
|
| 42 |
+
reader = csv.DictReader(f)
|
| 43 |
+
for row in reader:
|
| 44 |
+
rows.append(row)
|
| 45 |
+
if max_samples:
|
| 46 |
+
rows = rows[:max_samples]
|
| 47 |
+
logger.info(f"manifest ๋ก๋: {len(rows)}๊ฐ ์ํ")
|
| 48 |
+
return rows
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def evaluate_condition(
|
| 52 |
+
samples: list[dict],
|
| 53 |
+
condition: str,
|
| 54 |
+
device: str,
|
| 55 |
+
) -> dict:
|
| 56 |
+
"""ํ ์กฐ๊ฑด(clean/phone)์ ๋ํด ํ๊ฐ ์คํ."""
|
| 57 |
+
from sklearn.metrics import (
|
| 58 |
+
accuracy_score,
|
| 59 |
+
classification_report,
|
| 60 |
+
confusion_matrix,
|
| 61 |
+
)
|
| 62 |
+
from src.stage2.audio_emotion import predict as audio_predict
|
| 63 |
+
|
| 64 |
+
path_key = "clean_path" if condition == "clean" else "phone_path"
|
| 65 |
+
|
| 66 |
+
y_true = []
|
| 67 |
+
y_pred = []
|
| 68 |
+
latencies = []
|
| 69 |
+
errors = 0
|
| 70 |
+
|
| 71 |
+
total = len(samples)
|
| 72 |
+
for i, sample in enumerate(samples, 1):
|
| 73 |
+
audio_path = sample[path_key]
|
| 74 |
+
if not audio_path or not Path(audio_path).exists():
|
| 75 |
+
errors += 1
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
ground_truth = sample["emotion"]
|
| 79 |
+
|
| 80 |
+
t0 = time.perf_counter()
|
| 81 |
+
result = audio_predict(audio_path, device=device)
|
| 82 |
+
latency = (time.perf_counter() - t0) * 1000 # ms
|
| 83 |
+
|
| 84 |
+
y_true.append(ground_truth)
|
| 85 |
+
y_pred.append(result["emotion"])
|
| 86 |
+
latencies.append(latency)
|
| 87 |
+
|
| 88 |
+
if i % 200 == 0 or i == total:
|
| 89 |
+
acc_so_far = sum(1 for t, p in zip(y_true, y_pred) if t == p) / len(y_true)
|
| 90 |
+
logger.info(
|
| 91 |
+
f" [{condition}] {i}/{total} โ "
|
| 92 |
+
f"acc={acc_so_far:.3f}, "
|
| 93 |
+
f"avg_latency={sum(latencies)/len(latencies):.0f}ms"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
accuracy = accuracy_score(y_true, y_pred)
|
| 97 |
+
report = classification_report(
|
| 98 |
+
y_true, y_pred, labels=PROJECT_LABELS, output_dict=True, zero_division=0,
|
| 99 |
+
)
|
| 100 |
+
cm = confusion_matrix(y_true, y_pred, labels=PROJECT_LABELS)
|
| 101 |
+
|
| 102 |
+
# per-class metrics ์ ๋ฆฌ
|
| 103 |
+
per_class = {}
|
| 104 |
+
for label in PROJECT_LABELS:
|
| 105 |
+
if label in report:
|
| 106 |
+
per_class[label] = {
|
| 107 |
+
"precision": round(report[label]["precision"], 4),
|
| 108 |
+
"recall": round(report[label]["recall"], 4),
|
| 109 |
+
"f1": round(report[label]["f1-score"], 4),
|
| 110 |
+
"support": report[label]["support"],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
result = {
|
| 114 |
+
"condition": condition,
|
| 115 |
+
"total_samples": len(y_true),
|
| 116 |
+
"errors": errors,
|
| 117 |
+
"accuracy": round(accuracy, 4),
|
| 118 |
+
"macro_f1": round(report["macro avg"]["f1-score"], 4),
|
| 119 |
+
"weighted_f1": round(report["weighted avg"]["f1-score"], 4),
|
| 120 |
+
"per_class": per_class,
|
| 121 |
+
"confusion_matrix": cm.tolist(),
|
| 122 |
+
"confusion_labels": PROJECT_LABELS,
|
| 123 |
+
"avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else 0,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
logger.info(f"\n{'='*60}")
|
| 127 |
+
logger.info(f"[{condition.upper()}] ๊ฒฐ๊ณผ:")
|
| 128 |
+
logger.info(f" Accuracy: {accuracy:.4f}")
|
| 129 |
+
logger.info(f" Macro F1: {report['macro avg']['f1-score']:.4f}")
|
| 130 |
+
logger.info(f" Weighted F1: {report['weighted avg']['f1-score']:.4f}")
|
| 131 |
+
logger.info(f" Avg Latency: {result['avg_latency_ms']:.0f}ms")
|
| 132 |
+
logger.info(f"\nPer-class F1:")
|
| 133 |
+
for label in PROJECT_LABELS:
|
| 134 |
+
if label in per_class:
|
| 135 |
+
logger.info(f" {label:10s}: F1={per_class[label]['f1']:.3f} "
|
| 136 |
+
f"(P={per_class[label]['precision']:.3f} R={per_class[label]['recall']:.3f}) "
|
| 137 |
+
f"n={per_class[label]['support']}")
|
| 138 |
+
logger.info(f"\nConfusion Matrix (rows=true, cols=pred):")
|
| 139 |
+
logger.info(f" {'':10s} " + " ".join(f"{l[:4]:>6s}" for l in PROJECT_LABELS))
|
| 140 |
+
for i_row, label in enumerate(PROJECT_LABELS):
|
| 141 |
+
row_str = " ".join(f"{v:6d}" for v in cm[i_row])
|
| 142 |
+
logger.info(f" {label:10s} {row_str}")
|
| 143 |
+
logger.info(f"{'='*60}\n")
|
| 144 |
+
|
| 145 |
+
return result
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main():
|
| 149 |
+
parser = argparse.ArgumentParser(description="emotion2vec ์์ด ํ๊ฐ (RAVDESS)")
|
| 150 |
+
parser.add_argument("--condition", choices=["clean", "phone", "both"], default="both")
|
| 151 |
+
parser.add_argument("--device", default="cpu")
|
| 152 |
+
parser.add_argument("--max-samples", type=int, default=None, help="ํ๊ฐํ ์ต๋ ์ํ ์")
|
| 153 |
+
args = parser.parse_args()
|
| 154 |
+
|
| 155 |
+
if not MANIFEST_PATH.exists():
|
| 156 |
+
logger.error(f"manifest.csv๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. ๋จผ์ prepare_ravdess.py๋ฅผ ์คํํ์ธ์.")
|
| 157 |
+
sys.exit(1)
|
| 158 |
+
|
| 159 |
+
samples = load_manifest(args.max_samples)
|
| 160 |
+
|
| 161 |
+
conditions = []
|
| 162 |
+
if args.condition in ("clean", "both"):
|
| 163 |
+
conditions.append("clean")
|
| 164 |
+
if args.condition in ("phone", "both"):
|
| 165 |
+
conditions.append("phone")
|
| 166 |
+
|
| 167 |
+
results = []
|
| 168 |
+
for cond in conditions:
|
| 169 |
+
logger.info(f"\n{'#'*60}")
|
| 170 |
+
logger.info(f"ํ๊ฐ ์์: {cond.upper()} ์กฐ๊ฑด")
|
| 171 |
+
logger.info(f"{'#'*60}")
|
| 172 |
+
result = evaluate_condition(samples, cond, args.device)
|
| 173 |
+
results.append(result)
|
| 174 |
+
|
| 175 |
+
# ๊ฒฐ๊ณผ ์ ์ฅ
|
| 176 |
+
with open(OUTPUT_JSON, "w") as f:
|
| 177 |
+
json.dump(results, f, indent=2, ensure_ascii=False)
|
| 178 |
+
logger.info(f"๊ฒฐ๊ณผ ์ ์ฅ: {OUTPUT_JSON}")
|
| 179 |
+
|
| 180 |
+
# clean vs phone ๋น๊ต (both์ผ ๋)
|
| 181 |
+
if len(results) == 2:
|
| 182 |
+
clean_acc = results[0]["accuracy"]
|
| 183 |
+
phone_acc = results[1]["accuracy"]
|
| 184 |
+
degradation = clean_acc - phone_acc
|
| 185 |
+
logger.info(f"\n{'='*60}")
|
| 186 |
+
logger.info(f"Clean vs Phone ๋น๊ต:")
|
| 187 |
+
logger.info(f" Clean accuracy: {clean_acc:.4f}")
|
| 188 |
+
logger.info(f" Phone accuracy: {phone_acc:.4f}")
|
| 189 |
+
logger.info(f" Degradation: {degradation:+.4f}")
|
| 190 |
+
logger.info(f"{'='*60}")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
main()
|
scripts/export_lora_onnx.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""ONNX export for LoRA-finetuned emotion2vec 7-class model.
|
| 3 |
+
|
| 4 |
+
Merges LoRA weights into base model, wraps as a single waveform-to-logits
|
| 5 |
+
module, and exports to ONNX with dynamic batch/time axes.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/export_lora_onnx.py \
|
| 9 |
+
--checkpoint data/models/lora_emotion2vec_7class/best_lora.pt \
|
| 10 |
+
--output data/models/lora_emotion2vec_7class/emotion2vec_lora.onnx \
|
| 11 |
+
--device cpu
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import torch.nn.functional as F
|
| 24 |
+
|
| 25 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# Import LoRA components
|
| 29 |
+
import sys
|
| 30 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 31 |
+
|
| 32 |
+
from train_lora_emotion2vec import (
|
| 33 |
+
LoRALinear,
|
| 34 |
+
MLPHead,
|
| 35 |
+
inject_lora,
|
| 36 |
+
merge_lora_linear,
|
| 37 |
+
LABELS_7CLASS,
|
| 38 |
+
NUM_CLASSES,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def merge_all_lora(encoder: nn.Module) -> None:
|
| 43 |
+
"""Walk encoder.blocks, replace each LoRALinear with merged nn.Linear.
|
| 44 |
+
|
| 45 |
+
Modifies the encoder in-place.
|
| 46 |
+
"""
|
| 47 |
+
for block in encoder.blocks:
|
| 48 |
+
if isinstance(block.attn.qkv, LoRALinear):
|
| 49 |
+
block.attn.qkv = merge_lora_linear(block.attn.qkv)
|
| 50 |
+
if isinstance(block.attn.proj, LoRALinear):
|
| 51 |
+
block.attn.proj = merge_lora_linear(block.attn.proj)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Emotion2vecONNXWrapper(nn.Module):
|
| 55 |
+
"""Wraps emotion2vec encoder for ONNX export.
|
| 56 |
+
|
| 57 |
+
forward(waveform: (B, T)) -> logits: (B, 7)
|
| 58 |
+
Includes layer_norm + extract_features + mean pool + proj.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
def __init__(self, encoder):
|
| 62 |
+
super().__init__()
|
| 63 |
+
self.encoder = encoder
|
| 64 |
+
|
| 65 |
+
def forward(self, waveform: torch.Tensor) -> torch.Tensor:
|
| 66 |
+
"""
|
| 67 |
+
Args:
|
| 68 |
+
waveform: (B, T) float32, 16kHz
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
logits: (B, 7)
|
| 72 |
+
"""
|
| 73 |
+
# Layer norm (per-sample, ONNX-compatible: normalize along time axis)
|
| 74 |
+
if self.encoder.cfg.normalize:
|
| 75 |
+
mean = waveform.mean(dim=-1, keepdim=True)
|
| 76 |
+
var = waveform.var(dim=-1, keepdim=True, unbiased=False)
|
| 77 |
+
waveform = (waveform - mean) / torch.sqrt(var + 1e-5)
|
| 78 |
+
|
| 79 |
+
# Extract features
|
| 80 |
+
feats = self.encoder.extract_features(waveform, padding_mask=None)
|
| 81 |
+
x = feats["x"] # (B, T', 768)
|
| 82 |
+
|
| 83 |
+
# Mean pool
|
| 84 |
+
pooled = x.mean(dim=1) # (B, 768)
|
| 85 |
+
|
| 86 |
+
# Classify
|
| 87 |
+
logits = self.encoder.proj(pooled) # (B, 7)
|
| 88 |
+
return logits
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def export_onnx(checkpoint_path: str, output_path: str, device: str = "cpu"):
|
| 92 |
+
"""Load base emotion2vec, inject LoRA, load checkpoint, merge, export ONNX.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
checkpoint_path: path to LoRA checkpoint (.pt)
|
| 96 |
+
output_path: path for output ONNX file
|
| 97 |
+
device: "cpu" or "cuda"
|
| 98 |
+
"""
|
| 99 |
+
from funasr import AutoModel
|
| 100 |
+
|
| 101 |
+
checkpoint_path = Path(checkpoint_path)
|
| 102 |
+
output_path = Path(output_path)
|
| 103 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
|
| 105 |
+
# Load checkpoint to get config
|
| 106 |
+
logger.info("Loading checkpoint: %s", checkpoint_path)
|
| 107 |
+
ckpt = torch.load(str(checkpoint_path), map_location=device, weights_only=True)
|
| 108 |
+
|
| 109 |
+
# Load base model
|
| 110 |
+
logger.info("Loading emotion2vec_plus_base...")
|
| 111 |
+
fmodel = AutoModel(model="iic/emotion2vec_plus_base", device=device, hub="hf")
|
| 112 |
+
encoder = fmodel.model
|
| 113 |
+
|
| 114 |
+
# Freeze + inject LoRA (dropout=0 for inference)
|
| 115 |
+
for param in encoder.parameters():
|
| 116 |
+
param.requires_grad = False
|
| 117 |
+
inject_lora(encoder, r=16, alpha=32, dropout=0.0)
|
| 118 |
+
|
| 119 |
+
# Replace proj with MLPHead
|
| 120 |
+
num_classes = ckpt.get("num_classes", NUM_CLASSES)
|
| 121 |
+
encoder.proj = MLPHead(768, num_classes, dropout=0.0).to(device)
|
| 122 |
+
|
| 123 |
+
# Load LoRA weights
|
| 124 |
+
lora_weights = ckpt["lora_weights"]
|
| 125 |
+
for name, module in encoder.named_modules():
|
| 126 |
+
if isinstance(module, LoRALinear):
|
| 127 |
+
a_key = f"{name}.lora_A.weight"
|
| 128 |
+
b_key = f"{name}.lora_B.weight"
|
| 129 |
+
if a_key in lora_weights:
|
| 130 |
+
module.lora_A.weight.data.copy_(lora_weights[a_key])
|
| 131 |
+
if b_key in lora_weights:
|
| 132 |
+
module.lora_B.weight.data.copy_(lora_weights[b_key])
|
| 133 |
+
|
| 134 |
+
# Load proj state
|
| 135 |
+
encoder.proj.load_state_dict(ckpt["proj"])
|
| 136 |
+
|
| 137 |
+
# Merge LoRA into base weights
|
| 138 |
+
logger.info("Merging LoRA weights...")
|
| 139 |
+
merge_all_lora(encoder)
|
| 140 |
+
|
| 141 |
+
# Verify no LoRALinear remains
|
| 142 |
+
lora_count = sum(1 for m in encoder.modules() if isinstance(m, LoRALinear))
|
| 143 |
+
assert lora_count == 0, f"Merge failed: {lora_count} LoRALinear remain"
|
| 144 |
+
|
| 145 |
+
# Wrap for ONNX
|
| 146 |
+
wrapper = Emotion2vecONNXWrapper(encoder)
|
| 147 |
+
wrapper.eval()
|
| 148 |
+
|
| 149 |
+
# Dummy input (1 second of audio at 16kHz)
|
| 150 |
+
dummy_input = torch.randn(1, 16000, device=device)
|
| 151 |
+
|
| 152 |
+
# Export
|
| 153 |
+
logger.info("Exporting ONNX to %s ...", output_path)
|
| 154 |
+
torch.onnx.export(
|
| 155 |
+
wrapper,
|
| 156 |
+
dummy_input,
|
| 157 |
+
str(output_path),
|
| 158 |
+
opset_version=17,
|
| 159 |
+
input_names=["waveform"],
|
| 160 |
+
output_names=["logits"],
|
| 161 |
+
dynamic_axes={
|
| 162 |
+
"waveform": {0: "batch", 1: "time"},
|
| 163 |
+
"logits": {0: "batch"},
|
| 164 |
+
},
|
| 165 |
+
)
|
| 166 |
+
logger.info("ONNX export complete: %s", output_path)
|
| 167 |
+
|
| 168 |
+
# Save label metadata JSON alongside
|
| 169 |
+
meta_path = output_path.with_suffix(".json")
|
| 170 |
+
meta = {
|
| 171 |
+
"model": "emotion2vec_plus_base + LoRA (merged)",
|
| 172 |
+
"num_classes": num_classes,
|
| 173 |
+
"labels": ckpt.get("labels", LABELS_7CLASS),
|
| 174 |
+
"input": "waveform: (batch, time) float32, 16kHz mono",
|
| 175 |
+
"output": f"logits: (batch, {num_classes}) float32",
|
| 176 |
+
"checkpoint": str(checkpoint_path),
|
| 177 |
+
}
|
| 178 |
+
with open(meta_path, "w") as f:
|
| 179 |
+
json.dump(meta, f, indent=2)
|
| 180 |
+
logger.info("Metadata saved: %s", meta_path)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def export_kcelectra_onnx(model_dir: str, output_path: str, base_model_id: str = "beomi/KcELECTRA-base-v2022"):
|
| 184 |
+
"""Export LoRA-finetuned KcELECTRA to ONNX.
|
| 185 |
+
|
| 186 |
+
Merges PEFT LoRA into base model, then exports text โ logits ONNX.
|
| 187 |
+
"""
|
| 188 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 189 |
+
from peft import PeftModel
|
| 190 |
+
|
| 191 |
+
output_path = Path(output_path)
|
| 192 |
+
model_dir = Path(model_dir)
|
| 193 |
+
|
| 194 |
+
logger.info("Loading base model: %s", base_model_id)
|
| 195 |
+
base_model = AutoModelForSequenceClassification.from_pretrained(
|
| 196 |
+
base_model_id, num_labels=7,
|
| 197 |
+
)
|
| 198 |
+
tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
|
| 199 |
+
|
| 200 |
+
logger.info("Loading PEFT adapter: %s", model_dir)
|
| 201 |
+
model = PeftModel.from_pretrained(base_model, str(model_dir))
|
| 202 |
+
|
| 203 |
+
logger.info("Merging LoRA weights...")
|
| 204 |
+
model = model.merge_and_unload()
|
| 205 |
+
model.eval()
|
| 206 |
+
|
| 207 |
+
# Dummy input
|
| 208 |
+
dummy = tokenizer("ํ
์คํธ ๋ฌธ์ฅ์
๋๋ค", return_tensors="pt", max_length=128, truncation=True, padding="max_length")
|
| 209 |
+
|
| 210 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 211 |
+
logger.info("Exporting ONNX to %s", output_path)
|
| 212 |
+
|
| 213 |
+
torch.onnx.export(
|
| 214 |
+
model,
|
| 215 |
+
(dummy["input_ids"], dummy["attention_mask"]),
|
| 216 |
+
str(output_path),
|
| 217 |
+
opset_version=17,
|
| 218 |
+
input_names=["input_ids", "attention_mask"],
|
| 219 |
+
output_names=["logits"],
|
| 220 |
+
dynamic_axes={
|
| 221 |
+
"input_ids": {0: "batch", 1: "seq_len"},
|
| 222 |
+
"attention_mask": {0: "batch", 1: "seq_len"},
|
| 223 |
+
"logits": {0: "batch"},
|
| 224 |
+
},
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Save metadata
|
| 228 |
+
labels = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 229 |
+
meta_path = output_path.with_suffix(".json")
|
| 230 |
+
meta = {
|
| 231 |
+
"model": f"{base_model_id} + LoRA (merged)",
|
| 232 |
+
"num_classes": 7,
|
| 233 |
+
"labels": labels,
|
| 234 |
+
"input": "input_ids: (batch, seq_len) int64, attention_mask: (batch, seq_len) int64",
|
| 235 |
+
"output": "logits: (batch, 7) float32",
|
| 236 |
+
"max_length": 128,
|
| 237 |
+
}
|
| 238 |
+
with open(meta_path, "w") as f:
|
| 239 |
+
json.dump(meta, f, indent=2, ensure_ascii=False)
|
| 240 |
+
|
| 241 |
+
logger.info("ONNX export complete: %s (+ %s)", output_path, meta_path)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def main():
|
| 245 |
+
parser = argparse.ArgumentParser(description="Export LoRA models to ONNX")
|
| 246 |
+
parser.add_argument("--mode", required=True, choices=["audio", "text"],
|
| 247 |
+
help="audio: emotion2vec, text: KcELECTRA")
|
| 248 |
+
# Audio args
|
| 249 |
+
parser.add_argument("--checkpoint", help="Path to LoRA checkpoint (.pt) for audio mode")
|
| 250 |
+
# Text args
|
| 251 |
+
parser.add_argument("--model-dir", help="Path to PEFT model directory for text mode")
|
| 252 |
+
parser.add_argument("--base-model", default="beomi/KcELECTRA-base-v2022")
|
| 253 |
+
# Common
|
| 254 |
+
parser.add_argument("--output", required=True, help="Output ONNX path")
|
| 255 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 256 |
+
args = parser.parse_args()
|
| 257 |
+
|
| 258 |
+
if args.mode == "audio":
|
| 259 |
+
if not args.checkpoint:
|
| 260 |
+
parser.error("--checkpoint required for audio mode")
|
| 261 |
+
export_onnx(args.checkpoint, args.output, args.device)
|
| 262 |
+
else:
|
| 263 |
+
if not args.model_dir:
|
| 264 |
+
parser.error("--model-dir required for text mode")
|
| 265 |
+
export_kcelectra_onnx(args.model_dir, args.output, args.base_model)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
main()
|
scripts/optimize_fusion_weights.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Grid Search for Emotion-Specific Fusion Weights.
|
| 3 |
+
|
| 4 |
+
Uses AI Hub 263 val split (audio + text + ground truth) to find optimal
|
| 5 |
+
audio/text fusion weights per emotion class.
|
| 6 |
+
|
| 7 |
+
Outputs:
|
| 8 |
+
- fusion_grid_search.json โ full weight-F1 curves per emotion
|
| 9 |
+
- optimal_fusion_weights.json โ best weights per emotion
|
| 10 |
+
- fusion_grid_search.png โ 7 subplots: weight vs F1 per emotion
|
| 11 |
+
- fusion_comparison.png โ bar chart: fixed 60/40 vs optimal
|
| 12 |
+
- fusion_report.md โ text summary
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python scripts/optimize_fusion_weights.py \
|
| 16 |
+
--val-manifest data/lora_dataset/val_manifest.json \
|
| 17 |
+
--onnx-model data/models/lora_emotion2vec_7class/model.onnx \
|
| 18 |
+
--anchor-dir "data/AI Hub 263" \
|
| 19 |
+
--output-dir data/models/lora_emotion2vec_7class
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import csv
|
| 25 |
+
import gc
|
| 26 |
+
import json
|
| 27 |
+
import logging
|
| 28 |
+
import sys
|
| 29 |
+
from collections import Counter, defaultdict
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 38 |
+
|
| 39 |
+
# LoRA model labels โ project labels
|
| 40 |
+
LORA_LABELS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 41 |
+
LORA_TO_PROJECT = {
|
| 42 |
+
"happiness": "joy", "anger": "anger", "disgust": "disgust",
|
| 43 |
+
"fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# 263 label mapping (same as prepare_lora_dataset.py)
|
| 47 |
+
MAP_263 = {
|
| 48 |
+
"angry": "anger", "anger": "anger",
|
| 49 |
+
"sadness": "sadness", "sad": "sadness",
|
| 50 |
+
"happiness": "happiness", "happy": "happiness",
|
| 51 |
+
"fear": "fear", "disgust": "disgust",
|
| 52 |
+
"surprise": "surprise", "neutral": "neutral",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# KcELECTRA 44-class โ 7-class (from src/stage2/text_emotion.py)
|
| 56 |
+
KO_LABEL_MAP = {
|
| 57 |
+
"๊ธฐ์จ": "joy", "์ฆ๊ฑฐ์/์ ๋จ": "joy", "ํ๋ณต": "joy",
|
| 58 |
+
"๊ฐ๋/๊ฐํ": "joy", "๊ณ ๋ง์": "joy", "ํ์/ํธ์": "joy",
|
| 59 |
+
"๋ฟ๋ฏํจ": "joy", "ํ๋ญํจ(๊ท์ฌ์/์์จ)": "joy", "๊ธฐ๋๊ฐ": "joy",
|
| 60 |
+
"ํธ์/์พ์ ": "joy", "์์ฌ/์ ๋ขฐ": "joy", "์๊ปด์ฃผ๋": "joy", "์กด๊ฒฝ": "joy",
|
| 61 |
+
"๋๋": "surprise", "์ ๊ธฐํจ/๊ด์ฌ": "surprise", "๊ฒฝ์
": "surprise", "์ด์ด์์": "surprise",
|
| 62 |
+
"์ฌํ": "sadness", "์๋ฌ์": "sadness", "์ํ๊น์/์ค๋ง": "sadness",
|
| 63 |
+
"์ ๋ง": "sadness", "๋ถ๋๋ฌ์": "sadness", "๋ถ์ํจ/์ฐ๋ฏผ": "sadness",
|
| 64 |
+
"ํจ๋ฐฐ/์๊ธฐํ์ค": "sadness", "ํ๋ฆ/์ง์นจ": "sadness", "์ฃ์ฑ
๊ฐ": "sadness",
|
| 65 |
+
"ํ๋จ/๋ถ๋
ธ": "anger", "์ง์ฆ": "anger", "๋ถํ/๋ถ๋ง": "anger",
|
| 66 |
+
"์ง๊ธ์ง๊ธ": "anger", "์ฐ์ญ๋/๋ฌด์ํจ": "anger", "ํ์ฌํจ": "anger",
|
| 67 |
+
"์ฆ์ค/ํ์ค": "anger", "๊ท์ฐฎ์": "anger",
|
| 68 |
+
"๊ณตํฌ/๋ฌด์์": "fear", "๋ถ์/๊ฑฑ์ ": "fear", "๋นํฉ/๋์ฒ": "fear", "์์ฌ/๋ถ์ ": "fear",
|
| 69 |
+
"์์": "neutral", "๊นจ๋ฌ์": "neutral", "์ฌ๋ฏธ์์": "neutral",
|
| 70 |
+
"๋ถ๋ด/์_๋ดํด": "neutral", "๋น์ฅํจ": "neutral",
|
| 71 |
+
"์ญ๊ฒจ์/์ง๊ทธ๋ฌ์": "disgust",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def load_263_texts(anchor_dir: Path) -> dict[str, str]:
|
| 76 |
+
"""Load wav_id โ ๋ฐํ๋ฌธ mapping from 263 CSVs."""
|
| 77 |
+
texts = {}
|
| 78 |
+
for csv_path in sorted(anchor_dir.glob("*.csv")):
|
| 79 |
+
with open(csv_path, encoding="cp949") as f:
|
| 80 |
+
reader = csv.reader(f)
|
| 81 |
+
next(reader) # skip header
|
| 82 |
+
for row in reader:
|
| 83 |
+
wav_id = row[0]
|
| 84 |
+
text = row[1]
|
| 85 |
+
texts[wav_id] = text
|
| 86 |
+
logger.info("Loaded %d texts from 263 CSVs", len(texts))
|
| 87 |
+
return texts
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def predict_audio_base(audio_path: str, funasr_model, max_seconds: float = 15.0) -> dict[str, float]:
|
| 91 |
+
"""Run base (non-finetuned) emotion2vec via FunASR, 9-class โ 7-class mapping.
|
| 92 |
+
|
| 93 |
+
Audio trimmed to max_seconds โ FunASR transformer has quadratic memory in sequence length,
|
| 94 |
+
so a 100s clip can blow past 15GB RAM. Matches predict_audio_onnx() behavior.
|
| 95 |
+
"""
|
| 96 |
+
# emotion2vec base native labels โ project labels
|
| 97 |
+
LABEL_MAP = {
|
| 98 |
+
"angry": "anger", "disgusted": "disgust", "fearful": "fear",
|
| 99 |
+
"happy": "joy", "neutral": "neutral", "sad": "sadness", "surprised": "surprise",
|
| 100 |
+
"other": "neutral", "unknown": "neutral",
|
| 101 |
+
"็ๆฐ/angry": "anger", "ๅๆถ/disgusted": "disgust", "ๆๆง/fearful": "fear",
|
| 102 |
+
"ๅผๅฟ/happy": "joy", "ไธญ็ซ/neutral": "neutral", "้พ่ฟ/sad": "sadness",
|
| 103 |
+
"ๅๆ/surprised": "surprise", "ๅ
ถไป/other": "neutral", "<unk>": "neutral",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
import soundfile as sf
|
| 107 |
+
|
| 108 |
+
audio, sr = sf.read(audio_path, dtype="float32")
|
| 109 |
+
if audio.ndim == 2:
|
| 110 |
+
audio = audio.mean(axis=1)
|
| 111 |
+
if sr != 16000:
|
| 112 |
+
import librosa
|
| 113 |
+
audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
|
| 114 |
+
|
| 115 |
+
max_samples = int(max_seconds * 16000)
|
| 116 |
+
if len(audio) > max_samples:
|
| 117 |
+
audio = audio[:max_samples]
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
output = funasr_model.generate(
|
| 121 |
+
audio, granularity="utterance", extract_embedding=False,
|
| 122 |
+
)
|
| 123 |
+
except Exception:
|
| 124 |
+
return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
|
| 125 |
+
|
| 126 |
+
scores = {label: 0.0 for label in PROJECT_LABELS}
|
| 127 |
+
if output and isinstance(output, list) and len(output) > 0:
|
| 128 |
+
rec = output[0]
|
| 129 |
+
raw_labels = rec.get("labels", [])
|
| 130 |
+
raw_scores = rec.get("scores", [])
|
| 131 |
+
for native_label, score in zip(raw_labels, raw_scores):
|
| 132 |
+
project_label = LABEL_MAP.get(native_label, "neutral")
|
| 133 |
+
scores[project_label] += float(score)
|
| 134 |
+
|
| 135 |
+
total = sum(scores.values())
|
| 136 |
+
if total > 0:
|
| 137 |
+
scores = {k: v / total for k, v in scores.items()}
|
| 138 |
+
return scores
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def predict_audio_onnx(audio_path: str, session, max_seconds: float = 15.0) -> dict[str, float]:
|
| 142 |
+
"""Run ONNX audio emotion prediction (trimmed to max_seconds to avoid OOM)."""
|
| 143 |
+
import soundfile as sf
|
| 144 |
+
|
| 145 |
+
audio, sr = sf.read(audio_path, dtype="float32")
|
| 146 |
+
if audio.ndim == 2:
|
| 147 |
+
audio = audio.mean(axis=1)
|
| 148 |
+
if sr != 16000:
|
| 149 |
+
import librosa
|
| 150 |
+
audio = librosa.resample(audio, orig_sr=sr, target_sr=16000)
|
| 151 |
+
|
| 152 |
+
# Trim to max_seconds to prevent OOM on very long audio
|
| 153 |
+
max_samples = int(max_seconds * 16000)
|
| 154 |
+
if len(audio) > max_samples:
|
| 155 |
+
audio = audio[:max_samples]
|
| 156 |
+
|
| 157 |
+
waveform = audio.reshape(1, -1).astype(np.float32)
|
| 158 |
+
logits = session.run(None, {"waveform": waveform})[0]
|
| 159 |
+
|
| 160 |
+
exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
| 161 |
+
probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
|
| 162 |
+
|
| 163 |
+
scores = {}
|
| 164 |
+
for lora_label, prob in zip(LORA_LABELS, probs):
|
| 165 |
+
project_label = LORA_TO_PROJECT[lora_label]
|
| 166 |
+
scores[project_label] = float(prob)
|
| 167 |
+
return scores
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def predict_text_onnx(text: str, tokenizer, session) -> dict[str, float]:
|
| 171 |
+
"""Run fine-tuned KcELECTRA ONNX text emotion prediction (7-class direct)."""
|
| 172 |
+
if not text or not text.strip():
|
| 173 |
+
return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
|
| 174 |
+
|
| 175 |
+
enc = tokenizer(text, return_tensors="np", truncation=True, max_length=128, padding="max_length")
|
| 176 |
+
logits = session.run(None, {
|
| 177 |
+
"input_ids": enc["input_ids"],
|
| 178 |
+
"attention_mask": enc["attention_mask"],
|
| 179 |
+
})[0]
|
| 180 |
+
|
| 181 |
+
# Softmax
|
| 182 |
+
exp_logits = np.exp(logits - logits.max(axis=-1, keepdims=True))
|
| 183 |
+
probs = (exp_logits / exp_logits.sum(axis=-1, keepdims=True)).squeeze()
|
| 184 |
+
|
| 185 |
+
# LoRA KcELECTRA labels โ project labels (happiness โ joy)
|
| 186 |
+
text_labels = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 187 |
+
text_to_project = {
|
| 188 |
+
"happiness": "joy", "anger": "anger", "disgust": "disgust",
|
| 189 |
+
"fear": "fear", "neutral": "neutral", "sadness": "sadness", "surprise": "surprise",
|
| 190 |
+
}
|
| 191 |
+
scores = {label: 0.0 for label in PROJECT_LABELS}
|
| 192 |
+
for tl, prob in zip(text_labels, probs):
|
| 193 |
+
pl = text_to_project[tl]
|
| 194 |
+
scores[pl] = float(prob)
|
| 195 |
+
return scores
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def fuse_scores(audio_scores, text_scores, weights):
|
| 199 |
+
"""Fuse with emotion-specific weights."""
|
| 200 |
+
fused = {}
|
| 201 |
+
for label in PROJECT_LABELS:
|
| 202 |
+
aw = weights.get(label, {}).get("audio", 0.6)
|
| 203 |
+
tw = weights.get(label, {}).get("text", 0.4)
|
| 204 |
+
fused[label] = audio_scores.get(label, 0.0) * aw + text_scores.get(label, 0.0) * tw
|
| 205 |
+
|
| 206 |
+
total = sum(fused.values())
|
| 207 |
+
if total > 0:
|
| 208 |
+
fused = {k: v / total for k, v in fused.items()}
|
| 209 |
+
return fused
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def compute_f1(y_true, y_pred, target_label):
|
| 213 |
+
"""Compute F1 for a specific label (binary: target vs rest)."""
|
| 214 |
+
tp = sum(1 for t, p in zip(y_true, y_pred) if t == target_label and p == target_label)
|
| 215 |
+
fp = sum(1 for t, p in zip(y_true, y_pred) if t != target_label and p == target_label)
|
| 216 |
+
fn = sum(1 for t, p in zip(y_true, y_pred) if t == target_label and p != target_label)
|
| 217 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
|
| 218 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
|
| 219 |
+
if precision + recall == 0:
|
| 220 |
+
return 0.0
|
| 221 |
+
return 2 * precision * recall / (precision + recall)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def compute_macro_f1(y_true, y_pred):
|
| 225 |
+
"""Compute macro F1 across all 7 classes."""
|
| 226 |
+
f1s = [compute_f1(y_true, y_pred, label) for label in PROJECT_LABELS]
|
| 227 |
+
return np.mean(f1s)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def grid_search(samples, audio_preds, text_preds):
|
| 231 |
+
"""Run grid search for emotion-specific weights.
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
grid_results: dict[emotion] โ list of {"audio_weight": float, "f1": float}
|
| 235 |
+
optimal_weights: dict[emotion] โ {"audio": float, "text": float, "f1": float}
|
| 236 |
+
"""
|
| 237 |
+
weight_range = np.arange(0.0, 1.05, 0.05)
|
| 238 |
+
grid_results = {}
|
| 239 |
+
optimal_weights = {}
|
| 240 |
+
|
| 241 |
+
for target_emotion in PROJECT_LABELS:
|
| 242 |
+
results = []
|
| 243 |
+
best_f1 = -1
|
| 244 |
+
best_aw = 0.6
|
| 245 |
+
|
| 246 |
+
for aw in weight_range:
|
| 247 |
+
tw = 1.0 - aw
|
| 248 |
+
# Build per-emotion weight dict: target emotion uses (aw, tw), others use 0.6/0.4
|
| 249 |
+
weights = {}
|
| 250 |
+
for label in PROJECT_LABELS:
|
| 251 |
+
if label == target_emotion:
|
| 252 |
+
weights[label] = {"audio": float(aw), "text": float(tw)}
|
| 253 |
+
else:
|
| 254 |
+
weights[label] = {"audio": 0.6, "text": 0.4}
|
| 255 |
+
|
| 256 |
+
# Predict with these weights
|
| 257 |
+
y_true = [s["label"] for s in samples]
|
| 258 |
+
y_pred = []
|
| 259 |
+
for i, s in enumerate(samples):
|
| 260 |
+
fused = fuse_scores(audio_preds[i], text_preds[i], weights)
|
| 261 |
+
pred = max(fused, key=fused.get)
|
| 262 |
+
y_pred.append(pred)
|
| 263 |
+
|
| 264 |
+
f1 = compute_f1(y_true, y_pred, target_emotion)
|
| 265 |
+
results.append({"audio_weight": round(float(aw), 2), "f1": round(f1, 4)})
|
| 266 |
+
|
| 267 |
+
if f1 > best_f1:
|
| 268 |
+
best_f1 = f1
|
| 269 |
+
best_aw = float(aw)
|
| 270 |
+
|
| 271 |
+
grid_results[target_emotion] = results
|
| 272 |
+
optimal_weights[target_emotion] = {
|
| 273 |
+
"audio": round(best_aw, 2),
|
| 274 |
+
"text": round(1.0 - best_aw, 2),
|
| 275 |
+
"f1": round(best_f1, 4),
|
| 276 |
+
}
|
| 277 |
+
logger.info("%s: optimal audio_weight=%.2f (F1=%.4f)", target_emotion, best_aw, best_f1)
|
| 278 |
+
|
| 279 |
+
return grid_results, optimal_weights
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def compute_overall_comparison(samples, audio_preds, text_preds, optimal_weights):
|
| 283 |
+
"""Compare fixed 60/40 vs optimal weights on macro F1."""
|
| 284 |
+
fixed_weights = {label: {"audio": 0.6, "text": 0.4} for label in PROJECT_LABELS}
|
| 285 |
+
y_true = [s["label"] for s in samples]
|
| 286 |
+
|
| 287 |
+
# Fixed 60/40
|
| 288 |
+
y_pred_fixed = []
|
| 289 |
+
for i in range(len(samples)):
|
| 290 |
+
fused = fuse_scores(audio_preds[i], text_preds[i], fixed_weights)
|
| 291 |
+
y_pred_fixed.append(max(fused, key=fused.get))
|
| 292 |
+
fixed_macro = compute_macro_f1(y_true, y_pred_fixed)
|
| 293 |
+
fixed_per_class = {label: compute_f1(y_true, y_pred_fixed, label) for label in PROJECT_LABELS}
|
| 294 |
+
|
| 295 |
+
# Optimal
|
| 296 |
+
opt_weight_dict = {e: {"audio": w["audio"], "text": w["text"]} for e, w in optimal_weights.items()}
|
| 297 |
+
y_pred_opt = []
|
| 298 |
+
for i in range(len(samples)):
|
| 299 |
+
fused = fuse_scores(audio_preds[i], text_preds[i], opt_weight_dict)
|
| 300 |
+
y_pred_opt.append(max(fused, key=fused.get))
|
| 301 |
+
opt_macro = compute_macro_f1(y_true, y_pred_opt)
|
| 302 |
+
opt_per_class = {label: compute_f1(y_true, y_pred_opt, label) for label in PROJECT_LABELS}
|
| 303 |
+
|
| 304 |
+
# Audio-only baseline
|
| 305 |
+
y_pred_audio = []
|
| 306 |
+
for i in range(len(samples)):
|
| 307 |
+
pred = max(audio_preds[i], key=audio_preds[i].get)
|
| 308 |
+
y_pred_audio.append(pred)
|
| 309 |
+
audio_macro = compute_macro_f1(y_true, y_pred_audio)
|
| 310 |
+
audio_per_class = {label: compute_f1(y_true, y_pred_audio, label) for label in PROJECT_LABELS}
|
| 311 |
+
|
| 312 |
+
return {
|
| 313 |
+
"audio_only": {"macro_f1": round(audio_macro, 4), "per_class": {k: round(v, 4) for k, v in audio_per_class.items()}},
|
| 314 |
+
"fixed_60_40": {"macro_f1": round(fixed_macro, 4), "per_class": {k: round(v, 4) for k, v in fixed_per_class.items()}},
|
| 315 |
+
"optimal": {"macro_f1": round(opt_macro, 4), "per_class": {k: round(v, 4) for k, v in opt_per_class.items()}},
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def plot_grid_search(grid_results, optimal_weights, output_path: Path):
|
| 320 |
+
"""Plot 7 subplots: weight vs F1 per emotion."""
|
| 321 |
+
import matplotlib
|
| 322 |
+
matplotlib.use("Agg")
|
| 323 |
+
import matplotlib.pyplot as plt
|
| 324 |
+
|
| 325 |
+
fig, axes = plt.subplots(2, 4, figsize=(18, 9))
|
| 326 |
+
axes = axes.flatten()
|
| 327 |
+
|
| 328 |
+
for i, emotion in enumerate(PROJECT_LABELS):
|
| 329 |
+
ax = axes[i]
|
| 330 |
+
data = grid_results[emotion]
|
| 331 |
+
weights = [d["audio_weight"] for d in data]
|
| 332 |
+
f1s = [d["f1"] for d in data]
|
| 333 |
+
opt = optimal_weights[emotion]
|
| 334 |
+
|
| 335 |
+
ax.plot(weights, f1s, "b-o", markersize=3, linewidth=1.5)
|
| 336 |
+
ax.axvline(x=opt["audio"], color="r", linestyle="--", alpha=0.7,
|
| 337 |
+
label=f"optimal={opt['audio']:.2f}")
|
| 338 |
+
ax.axvline(x=0.6, color="gray", linestyle=":", alpha=0.5, label="fixed=0.60")
|
| 339 |
+
ax.set_title(f"{emotion} (best F1={opt['f1']:.3f})", fontsize=11, fontweight="bold")
|
| 340 |
+
ax.set_xlabel("Audio Weight")
|
| 341 |
+
ax.set_ylabel("F1 Score")
|
| 342 |
+
ax.set_xlim(-0.05, 1.05)
|
| 343 |
+
ax.legend(fontsize=8)
|
| 344 |
+
ax.grid(True, alpha=0.3)
|
| 345 |
+
|
| 346 |
+
# Hide last subplot (2x4 = 8, but only 7 emotions)
|
| 347 |
+
axes[7].set_visible(False)
|
| 348 |
+
|
| 349 |
+
fig.suptitle("Emotion-Specific Fusion Weight Grid Search", fontsize=14, fontweight="bold")
|
| 350 |
+
plt.tight_layout()
|
| 351 |
+
plt.savefig(str(output_path), dpi=150)
|
| 352 |
+
plt.close()
|
| 353 |
+
logger.info("Grid search plot saved: %s", output_path)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def plot_comparison(comparison, optimal_weights, output_path: Path):
|
| 357 |
+
"""Bar chart: audio-only vs fixed 60/40 vs optimal per emotion."""
|
| 358 |
+
import matplotlib
|
| 359 |
+
matplotlib.use("Agg")
|
| 360 |
+
import matplotlib.pyplot as plt
|
| 361 |
+
|
| 362 |
+
emotions = PROJECT_LABELS
|
| 363 |
+
audio_f1s = [comparison["audio_only"]["per_class"][e] for e in emotions]
|
| 364 |
+
fixed_f1s = [comparison["fixed_60_40"]["per_class"][e] for e in emotions]
|
| 365 |
+
opt_f1s = [comparison["optimal"]["per_class"][e] for e in emotions]
|
| 366 |
+
|
| 367 |
+
x = np.arange(len(emotions))
|
| 368 |
+
width = 0.25
|
| 369 |
+
|
| 370 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
| 371 |
+
bars1 = ax.bar(x - width, audio_f1s, width, label=f"Audio Only (macro={comparison['audio_only']['macro_f1']:.3f})", color="#2196F3", alpha=0.8)
|
| 372 |
+
bars2 = ax.bar(x, fixed_f1s, width, label=f"Fixed 60/40 (macro={comparison['fixed_60_40']['macro_f1']:.3f})", color="#FF9800", alpha=0.8)
|
| 373 |
+
bars3 = ax.bar(x + width, opt_f1s, width, label=f"Optimal (macro={comparison['optimal']['macro_f1']:.3f})", color="#4CAF50", alpha=0.8)
|
| 374 |
+
|
| 375 |
+
# Add weight annotations on optimal bars
|
| 376 |
+
for i, e in enumerate(emotions):
|
| 377 |
+
aw = optimal_weights[e]["audio"]
|
| 378 |
+
ax.text(x[i] + width, opt_f1s[i] + 0.01, f"a={aw:.0%}", ha="center", fontsize=7, color="#2E7D32")
|
| 379 |
+
|
| 380 |
+
ax.set_ylabel("F1 Score")
|
| 381 |
+
ax.set_title("Fusion Strategy Comparison: Audio Only vs Fixed 60/40 vs Emotion-Specific Optimal", fontweight="bold")
|
| 382 |
+
ax.set_xticks(x)
|
| 383 |
+
ax.set_xticklabels(emotions, fontsize=10)
|
| 384 |
+
ax.legend(fontsize=10)
|
| 385 |
+
ax.set_ylim(0, 1.0)
|
| 386 |
+
ax.grid(axis="y", alpha=0.3)
|
| 387 |
+
|
| 388 |
+
plt.tight_layout()
|
| 389 |
+
plt.savefig(str(output_path), dpi=150)
|
| 390 |
+
plt.close()
|
| 391 |
+
logger.info("Comparison plot saved: %s", output_path)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def write_report(comparison, optimal_weights, output_path: Path):
|
| 395 |
+
"""Write markdown summary report."""
|
| 396 |
+
lines = [
|
| 397 |
+
"# Fusion Weight Optimization Report",
|
| 398 |
+
"",
|
| 399 |
+
"## Summary",
|
| 400 |
+
"",
|
| 401 |
+
f"| Strategy | Macro F1 |",
|
| 402 |
+
f"|---|---|",
|
| 403 |
+
f"| Audio Only | {comparison['audio_only']['macro_f1']:.4f} |",
|
| 404 |
+
f"| Fixed 60/40 | {comparison['fixed_60_40']['macro_f1']:.4f} |",
|
| 405 |
+
f"| **Emotion-Specific Optimal** | **{comparison['optimal']['macro_f1']:.4f}** |",
|
| 406 |
+
f"| Improvement over Fixed | **+{comparison['optimal']['macro_f1'] - comparison['fixed_60_40']['macro_f1']:.4f}** |",
|
| 407 |
+
"",
|
| 408 |
+
"## Optimal Weights Per Emotion",
|
| 409 |
+
"",
|
| 410 |
+
"| Emotion | Audio Weight | Text Weight | F1 (optimal) | F1 (fixed 60/40) | Delta |",
|
| 411 |
+
"|---|---|---|---|---|---|",
|
| 412 |
+
]
|
| 413 |
+
for e in PROJECT_LABELS:
|
| 414 |
+
aw = optimal_weights[e]["audio"]
|
| 415 |
+
tw = optimal_weights[e]["text"]
|
| 416 |
+
opt_f1 = comparison["optimal"]["per_class"][e]
|
| 417 |
+
fixed_f1 = comparison["fixed_60_40"]["per_class"][e]
|
| 418 |
+
delta = opt_f1 - fixed_f1
|
| 419 |
+
sign = "+" if delta >= 0 else ""
|
| 420 |
+
lines.append(f"| {e} | {aw:.0%} | {tw:.0%} | {opt_f1:.4f} | {fixed_f1:.4f} | {sign}{delta:.4f} |")
|
| 421 |
+
|
| 422 |
+
lines.extend([
|
| 423 |
+
"",
|
| 424 |
+
"## Methodology",
|
| 425 |
+
"",
|
| 426 |
+
"- **Data:** AI Hub 263 val split (1,294 samples, 7-class, speaker-isolated)",
|
| 427 |
+
"- **Audio model:** LoRA emotion2vec ONNX (7-class, macro F1=0.552)",
|
| 428 |
+
"- **Text model:** KcELECTRA LoRA fine-tuned (beomi/KcELECTRA-base-v2022, 7-class direct)",
|
| 429 |
+
"- **Search:** Per-emotion audio weight 0.0~1.0 in 0.05 steps (21 points ร 7 emotions)",
|
| 430 |
+
"- **Metric:** Per-emotion F1 score on val set",
|
| 431 |
+
"",
|
| 432 |
+
"## Files",
|
| 433 |
+
"",
|
| 434 |
+
"- `fusion_grid_search.json` โ full weight-F1 curve data",
|
| 435 |
+
"- `optimal_fusion_weights.json` โ best weights",
|
| 436 |
+
"- `fusion_grid_search.png` โ per-emotion weight vs F1 plots",
|
| 437 |
+
"- `fusion_comparison.png` โ strategy comparison bar chart",
|
| 438 |
+
])
|
| 439 |
+
|
| 440 |
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
| 441 |
+
logger.info("Report saved: %s", output_path)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def predict_text_distilroberta(text: str, tokenizer, model) -> dict[str, float]:
|
| 445 |
+
"""Run j-hartmann/DistilRoBERTa text emotion prediction (7-class direct)."""
|
| 446 |
+
import torch
|
| 447 |
+
|
| 448 |
+
if not text or not text.strip():
|
| 449 |
+
return {label: 1.0 / len(PROJECT_LABELS) for label in PROJECT_LABELS}
|
| 450 |
+
|
| 451 |
+
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
|
| 452 |
+
with torch.no_grad():
|
| 453 |
+
outputs = model(**inputs)
|
| 454 |
+
probs = torch.softmax(outputs.logits, dim=-1).squeeze().cpu().numpy()
|
| 455 |
+
|
| 456 |
+
# DistilRoBERTa labels: anger, disgust, fear, joy, neutral, sadness, surprise
|
| 457 |
+
dr_labels = [model.config.id2label[i] for i in range(len(probs))]
|
| 458 |
+
scores = {label: 0.0 for label in PROJECT_LABELS}
|
| 459 |
+
for dl, prob in zip(dr_labels, probs):
|
| 460 |
+
if dl in scores:
|
| 461 |
+
scores[dl] = float(prob)
|
| 462 |
+
return scores
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def main():
|
| 466 |
+
parser = argparse.ArgumentParser(description="Optimize emotion-specific fusion weights")
|
| 467 |
+
parser.add_argument("--lang", default="ko", choices=["ko", "en"], help="Language: ko=Korean, en=English")
|
| 468 |
+
parser.add_argument("--val-manifest", type=Path, default=Path("data/lora_dataset/val_manifest.json"))
|
| 469 |
+
parser.add_argument("--onnx-model", type=Path, default=Path("data/models/lora_emotion2vec_7class/model.onnx"))
|
| 470 |
+
parser.add_argument("--anchor-dir", type=Path, default=Path("data/AI Hub 263"))
|
| 471 |
+
parser.add_argument("--output-dir", type=Path, default=Path("data/models/fusion_optimization"))
|
| 472 |
+
parser.add_argument("--text-onnx", type=Path, default=Path("data/models/lora_kcelectra_7class/model.onnx"))
|
| 473 |
+
parser.add_argument("--text-tokenizer", default="data/models/lora_kcelectra_7class/best_model")
|
| 474 |
+
parser.add_argument("--en-text-model", default="j-hartmann/emotion-english-distilroberta-base")
|
| 475 |
+
parser.add_argument("--use-base-audio", action="store_true",
|
| 476 |
+
help="Use base (non-finetuned) emotion2vec via FunASR instead of LoRA ONNX")
|
| 477 |
+
args = parser.parse_args()
|
| 478 |
+
|
| 479 |
+
lang = args.lang
|
| 480 |
+
prefix = "en_" if lang == "en" else ""
|
| 481 |
+
output_dir = args.output_dir
|
| 482 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 483 |
+
|
| 484 |
+
# Step 1: Load manifest
|
| 485 |
+
with open(args.val_manifest, encoding="utf-8") as f:
|
| 486 |
+
val_all = json.load(f)
|
| 487 |
+
|
| 488 |
+
if lang == "ko":
|
| 489 |
+
# Korean: 263 val only
|
| 490 |
+
samples = [s for s in val_all if s.get("source") == "263"]
|
| 491 |
+
logger.info("Korean 263 val samples: %d", len(samples))
|
| 492 |
+
else:
|
| 493 |
+
# English: MELD test (all samples have text)
|
| 494 |
+
samples = val_all
|
| 495 |
+
logger.info("English MELD test samples: %d", len(samples))
|
| 496 |
+
|
| 497 |
+
# Map label: happiness โ joy for consistency
|
| 498 |
+
for s in samples:
|
| 499 |
+
if s["label"] == "happiness":
|
| 500 |
+
s["label"] = "joy"
|
| 501 |
+
|
| 502 |
+
# Step 2: Filter samples with text
|
| 503 |
+
matched = [s for s in samples if s.get("text", "").strip()]
|
| 504 |
+
|
| 505 |
+
# Korean fallback: load from CSV if no text in manifest
|
| 506 |
+
if not matched and lang == "ko":
|
| 507 |
+
logger.info("No text in manifest, loading from 263 CSVs...")
|
| 508 |
+
texts_map = load_263_texts(args.anchor_dir)
|
| 509 |
+
for s in samples:
|
| 510 |
+
wav_id = Path(s["path"]).stem
|
| 511 |
+
text = texts_map.get(wav_id, "")
|
| 512 |
+
if text:
|
| 513 |
+
s["text"] = text
|
| 514 |
+
matched.append(s)
|
| 515 |
+
|
| 516 |
+
logger.info("Matched audio+text: %d / %d", len(matched), len(samples))
|
| 517 |
+
if len(matched) < 50:
|
| 518 |
+
logger.error("Too few matched samples.")
|
| 519 |
+
sys.exit(1)
|
| 520 |
+
|
| 521 |
+
# Step 3: Load models
|
| 522 |
+
import onnxruntime as ort
|
| 523 |
+
|
| 524 |
+
if args.use_base_audio:
|
| 525 |
+
from funasr import AutoModel
|
| 526 |
+
logger.info("Loading base emotion2vec_plus_base via FunASR (not LoRA)...")
|
| 527 |
+
funasr_model = AutoModel(model="iic/emotion2vec_plus_base", device="cpu", hub="hf")
|
| 528 |
+
audio_predict_fn = lambda path: predict_audio_base(path, funasr_model)
|
| 529 |
+
else:
|
| 530 |
+
logger.info("Loading audio ONNX (LoRA): %s", args.onnx_model)
|
| 531 |
+
onnx_session = ort.InferenceSession(str(args.onnx_model), providers=["CPUExecutionProvider"])
|
| 532 |
+
audio_predict_fn = lambda path: predict_audio_onnx(path, onnx_session)
|
| 533 |
+
|
| 534 |
+
if lang == "ko":
|
| 535 |
+
from transformers import AutoTokenizer
|
| 536 |
+
logger.info("Loading KcELECTRA ONNX: %s", args.text_onnx)
|
| 537 |
+
text_session = ort.InferenceSession(str(args.text_onnx), providers=["CPUExecutionProvider"])
|
| 538 |
+
tokenizer = AutoTokenizer.from_pretrained(args.text_tokenizer)
|
| 539 |
+
text_predict_fn = lambda text: predict_text_onnx(text, tokenizer, text_session)
|
| 540 |
+
else:
|
| 541 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 542 |
+
logger.info("Loading DistilRoBERTa: %s", args.en_text_model)
|
| 543 |
+
en_tokenizer = AutoTokenizer.from_pretrained(args.en_text_model)
|
| 544 |
+
en_model = AutoModelForSequenceClassification.from_pretrained(args.en_text_model)
|
| 545 |
+
en_model.eval()
|
| 546 |
+
text_predict_fn = lambda text: predict_text_distilroberta(text, en_tokenizer, en_model)
|
| 547 |
+
|
| 548 |
+
# Step 4: Predict all samples (with checkpoint for resume safety)
|
| 549 |
+
preds_cache_path = output_dir / f"{prefix}preds_cache.json"
|
| 550 |
+
audio_preds = []
|
| 551 |
+
text_preds = []
|
| 552 |
+
start_idx = 0
|
| 553 |
+
|
| 554 |
+
if preds_cache_path.exists():
|
| 555 |
+
with open(preds_cache_path) as f:
|
| 556 |
+
cache = json.load(f)
|
| 557 |
+
audio_preds = cache.get("audio_preds", [])
|
| 558 |
+
text_preds = cache.get("text_preds", [])
|
| 559 |
+
start_idx = len(audio_preds)
|
| 560 |
+
logger.info("Resumed from checkpoint: %d predictions already done", start_idx)
|
| 561 |
+
|
| 562 |
+
for i in range(start_idx, len(matched)):
|
| 563 |
+
s = matched[i]
|
| 564 |
+
audio_scores = audio_predict_fn(s["path"])
|
| 565 |
+
audio_preds.append(audio_scores)
|
| 566 |
+
|
| 567 |
+
text_scores = text_predict_fn(s["text"])
|
| 568 |
+
text_preds.append(text_scores)
|
| 569 |
+
|
| 570 |
+
# FunASR/PyTorch leak audio tensors across .generate() calls โ force release every 25 samples
|
| 571 |
+
if (i + 1) % 25 == 0:
|
| 572 |
+
gc.collect()
|
| 573 |
+
try:
|
| 574 |
+
import torch
|
| 575 |
+
if torch.cuda.is_available():
|
| 576 |
+
torch.cuda.empty_cache()
|
| 577 |
+
except ImportError:
|
| 578 |
+
pass
|
| 579 |
+
|
| 580 |
+
# Checkpoint every 100 samples
|
| 581 |
+
if (i + 1) % 100 == 0:
|
| 582 |
+
logger.info("Predicted %d / %d (saving checkpoint)", i + 1, len(matched))
|
| 583 |
+
with open(preds_cache_path, "w") as f:
|
| 584 |
+
json.dump({"audio_preds": audio_preds, "text_preds": text_preds}, f)
|
| 585 |
+
|
| 586 |
+
# Final checkpoint save
|
| 587 |
+
with open(preds_cache_path, "w") as f:
|
| 588 |
+
json.dump({"audio_preds": audio_preds, "text_preds": text_preds}, f)
|
| 589 |
+
|
| 590 |
+
logger.info("All predictions done (%d samples)", len(matched))
|
| 591 |
+
|
| 592 |
+
# Step 5: Grid search
|
| 593 |
+
grid_results, optimal_weights = grid_search(matched, audio_preds, text_preds)
|
| 594 |
+
|
| 595 |
+
# Step 6: Overall comparison
|
| 596 |
+
comparison = compute_overall_comparison(matched, audio_preds, text_preds, optimal_weights)
|
| 597 |
+
logger.info("Audio-only macro F1: %.4f", comparison["audio_only"]["macro_f1"])
|
| 598 |
+
logger.info("Fixed 60/40 macro F1: %.4f", comparison["fixed_60_40"]["macro_f1"])
|
| 599 |
+
logger.info("Optimal macro F1: %.4f", comparison["optimal"]["macro_f1"])
|
| 600 |
+
|
| 601 |
+
# Step 7: Save everything
|
| 602 |
+
with open(output_dir / f"{prefix}fusion_grid_search.json", "w") as f:
|
| 603 |
+
json.dump(grid_results, f, indent=2)
|
| 604 |
+
with open(output_dir / f"{prefix}optimal_fusion_weights.json", "w") as f:
|
| 605 |
+
json.dump(optimal_weights, f, indent=2, ensure_ascii=False)
|
| 606 |
+
with open(output_dir / f"{prefix}fusion_comparison.json", "w") as f:
|
| 607 |
+
json.dump(comparison, f, indent=2)
|
| 608 |
+
|
| 609 |
+
# Step 8: Plots + report
|
| 610 |
+
plot_grid_search(grid_results, optimal_weights, output_dir / f"{prefix}fusion_grid_search.png")
|
| 611 |
+
plot_comparison(comparison, optimal_weights, output_dir / f"{prefix}fusion_comparison.png")
|
| 612 |
+
write_report(comparison, optimal_weights, output_dir / f"{prefix}fusion_report.md")
|
| 613 |
+
|
| 614 |
+
logger.info("Done! All results saved to %s", output_dir)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
if __name__ == "__main__":
|
| 618 |
+
main()
|
scripts/prepare_aihub_test_subset.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""AI Hub ๊ฐ์ ํ๊น
์์ ๋ํ(์ฑ์ธ) ๋ฐ์ดํฐ์
โ ๋ฒค์น๋งํฌ ํ
์คํธ ์๋ธ์
์ค๋น.
|
| 3 |
+
|
| 4 |
+
AI Hub #71631 ๋ฐ์ดํฐ์
์ JSON ๋ผ๋ฒจ + ์คํ
๋ ์ค WAV์์ ๋ฐํ ๋จ์๋ฅผ ์ถ์ถํ์ฌ
|
| 5 |
+
๊ท ํ ์กํ 6-class ํ
์คํธ์
์ ์์ฑํ๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/prepare_aihub_test_subset.py --aihub-dir data/samples
|
| 9 |
+
python scripts/prepare_aihub_test_subset.py --aihub-dir /path/to/full/dataset --samples-per-class 83
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import csv
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import random
|
| 20 |
+
import sys
|
| 21 |
+
from collections import defaultdict
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import soundfile as sf
|
| 26 |
+
|
| 27 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 31 |
+
# Label Mapping: AI Hub ํ๊ตญ์ด โ Project 6-class
|
| 32 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 33 |
+
|
| 34 |
+
AIHUB_LABEL_MAP = {
|
| 35 |
+
"๊ธฐ์จ": "joy",
|
| 36 |
+
"๋๋ผ์": "surprise",
|
| 37 |
+
"๋๋ ค์": "fear",
|
| 38 |
+
"์ฌ๋์ค๋ฌ์": "joy", # Affection โ joy (user confirmed)
|
| 39 |
+
"์ฌํ": "sadness",
|
| 40 |
+
"ํ๋จ": "anger",
|
| 41 |
+
"์์": "neutral",
|
| 42 |
+
"์ค๋ฆฝ": "neutral", # appears in SpeakerEmotionCategory
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
|
| 46 |
+
|
| 47 |
+
# Minimum utterance duration (seconds) โ too short = unreliable emotion
|
| 48 |
+
MIN_DURATION_SEC = 0.5
|
| 49 |
+
# Maximum utterance duration โ cap very long utterances
|
| 50 |
+
MAX_DURATION_SEC = 30.0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 54 |
+
# Step 1: Parse AI Hub JSON + discover WAV pairs
|
| 55 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 56 |
+
|
| 57 |
+
def discover_pairs(aihub_dir: str) -> list[tuple[Path, Path]]:
|
| 58 |
+
"""Find matched WAV-JSON file pairs in AI Hub directory structure.
|
| 59 |
+
|
| 60 |
+
Expected structure:
|
| 61 |
+
aihub_dir/01.์์ฒ๋ฐ์ดํฐ/{01.์ค๋ด,02.์ค์ธ}/xxx.wav
|
| 62 |
+
aihub_dir/02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ/{01.์ค๋ด,02.์ค์ธ}/xxx.json
|
| 63 |
+
"""
|
| 64 |
+
source_dir = Path(aihub_dir) / "01.์์ฒ๋ฐ์ดํฐ"
|
| 65 |
+
label_dir = Path(aihub_dir) / "02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ"
|
| 66 |
+
|
| 67 |
+
if not source_dir.exists() or not label_dir.exists():
|
| 68 |
+
logger.error("Expected 01.์์ฒ๋ฐ์ดํฐ and 02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ under %s", aihub_dir)
|
| 69 |
+
sys.exit(1)
|
| 70 |
+
|
| 71 |
+
# Build WAV lookup: stem โ path
|
| 72 |
+
wav_lookup = {}
|
| 73 |
+
for wav_path in source_dir.rglob("*.wav"):
|
| 74 |
+
if str(wav_path).endswith(":Zone.Identifier"):
|
| 75 |
+
continue
|
| 76 |
+
wav_lookup[wav_path.stem] = wav_path
|
| 77 |
+
|
| 78 |
+
# Match JSON โ WAV
|
| 79 |
+
pairs = []
|
| 80 |
+
for json_path in label_dir.rglob("*.json"):
|
| 81 |
+
if str(json_path).endswith(":Zone.Identifier"):
|
| 82 |
+
continue
|
| 83 |
+
stem = json_path.stem
|
| 84 |
+
wav_path = wav_lookup.get(stem)
|
| 85 |
+
if wav_path:
|
| 86 |
+
pairs.append((wav_path, json_path))
|
| 87 |
+
else:
|
| 88 |
+
logger.warning("No WAV match for %s", json_path.name)
|
| 89 |
+
|
| 90 |
+
logger.info("Discovered %d WAV-JSON pairs", len(pairs))
|
| 91 |
+
return pairs
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def parse_utterances(pairs: list[tuple[Path, Path]]) -> list[dict]:
|
| 95 |
+
"""Parse all utterances from JSON label files.
|
| 96 |
+
|
| 97 |
+
Uses VerifyEmotionTarget as ground truth (annotator-verified label).
|
| 98 |
+
"""
|
| 99 |
+
utterances = []
|
| 100 |
+
|
| 101 |
+
for wav_path, json_path in pairs:
|
| 102 |
+
with open(json_path, encoding="utf-8") as f:
|
| 103 |
+
data = json.load(f)
|
| 104 |
+
|
| 105 |
+
wav_info = data.get("Wav", {})
|
| 106 |
+
file_info = data.get("File", {})
|
| 107 |
+
sr = int(wav_info.get("SamplingRate", 16000))
|
| 108 |
+
n_channels = int(wav_info.get("NumberOfChannel", 2))
|
| 109 |
+
|
| 110 |
+
# Speaker info
|
| 111 |
+
speakers = {}
|
| 112 |
+
for key in ("Speaker1", "Speaker2"):
|
| 113 |
+
spk = data.get(key, {})
|
| 114 |
+
speakers[key] = {
|
| 115 |
+
"id": spk.get("ID", ""),
|
| 116 |
+
"gender": spk.get("Gender", ""),
|
| 117 |
+
"age": spk.get("Age", ""),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
for utt in data.get("Conversation", []):
|
| 121 |
+
emotion_kr = utt.get("VerifyEmotionTarget", "").strip()
|
| 122 |
+
emotion_en = AIHUB_LABEL_MAP.get(emotion_kr)
|
| 123 |
+
if emotion_en is None:
|
| 124 |
+
continue # Unknown label, skip
|
| 125 |
+
|
| 126 |
+
if emotion_en not in EVAL_LABELS:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
start = float(str(utt["StartTime"]).replace(",", ""))
|
| 131 |
+
end = float(str(utt["EndTime"]).replace(",", ""))
|
| 132 |
+
except (KeyError, ValueError):
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
duration = end - start
|
| 136 |
+
if duration < MIN_DURATION_SEC or duration > MAX_DURATION_SEC:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
speaker_no = utt.get("SpeakerNo", "Speaker1")
|
| 140 |
+
speaker_info = speakers.get(speaker_no, {})
|
| 141 |
+
|
| 142 |
+
# Determine which channel to extract (0-indexed)
|
| 143 |
+
# Speaker1 = left channel (0), Speaker2 = right channel (1)
|
| 144 |
+
channel = 0 if speaker_no == "Speaker1" else 1
|
| 145 |
+
if n_channels == 1:
|
| 146 |
+
channel = 0
|
| 147 |
+
|
| 148 |
+
utterances.append({
|
| 149 |
+
"wav_path": str(wav_path),
|
| 150 |
+
"json_path": str(json_path),
|
| 151 |
+
"file_stem": wav_path.stem,
|
| 152 |
+
"text_no": utt.get("TextNo", ""),
|
| 153 |
+
"text": utt.get("Text", ""),
|
| 154 |
+
"start": start,
|
| 155 |
+
"end": end,
|
| 156 |
+
"duration": duration,
|
| 157 |
+
"emotion": emotion_en,
|
| 158 |
+
"emotion_kr": emotion_kr,
|
| 159 |
+
"intensity": utt.get("VerifyEmotionLevel", ""),
|
| 160 |
+
"speaker_no": speaker_no,
|
| 161 |
+
"speaker_id": speaker_info.get("id", ""),
|
| 162 |
+
"speaker_gender": speaker_info.get("gender", ""),
|
| 163 |
+
"speaker_age": speaker_info.get("age", ""),
|
| 164 |
+
"channel": channel,
|
| 165 |
+
"sample_rate": sr,
|
| 166 |
+
"n_channels": n_channels,
|
| 167 |
+
})
|
| 168 |
+
|
| 169 |
+
logger.info("Parsed %d valid utterances across %d files", len(utterances), len(pairs))
|
| 170 |
+
return utterances
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 174 |
+
# Step 2: Balanced sampling
|
| 175 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 176 |
+
|
| 177 |
+
def balanced_sample(
|
| 178 |
+
utterances: list[dict],
|
| 179 |
+
samples_per_class: int,
|
| 180 |
+
seed: int = 42,
|
| 181 |
+
) -> list[dict]:
|
| 182 |
+
"""Stratified balanced sampling: target samples_per_class per emotion.
|
| 183 |
+
|
| 184 |
+
Ensures:
|
| 185 |
+
- Duration diversity (short/medium/long mix)
|
| 186 |
+
- Speaker diversity (spread across speakers)
|
| 187 |
+
- For rare classes (e.g., fear), takes all available if < target
|
| 188 |
+
"""
|
| 189 |
+
rng = random.Random(seed)
|
| 190 |
+
|
| 191 |
+
# Group by emotion
|
| 192 |
+
by_emotion: dict[str, list[dict]] = defaultdict(list)
|
| 193 |
+
for utt in utterances:
|
| 194 |
+
by_emotion[utt["emotion"]].append(utt)
|
| 195 |
+
|
| 196 |
+
selected = []
|
| 197 |
+
stats = {}
|
| 198 |
+
|
| 199 |
+
for emotion in EVAL_LABELS:
|
| 200 |
+
pool = by_emotion.get(emotion, [])
|
| 201 |
+
if not pool:
|
| 202 |
+
logger.warning("No samples for emotion '%s'", emotion)
|
| 203 |
+
stats[emotion] = 0
|
| 204 |
+
continue
|
| 205 |
+
|
| 206 |
+
if len(pool) <= samples_per_class:
|
| 207 |
+
# Take all for rare classes
|
| 208 |
+
chosen = pool
|
| 209 |
+
else:
|
| 210 |
+
# Duration-stratified sampling
|
| 211 |
+
short = [u for u in pool if u["duration"] < 3.0]
|
| 212 |
+
medium = [u for u in pool if 3.0 <= u["duration"] < 10.0]
|
| 213 |
+
long = [u for u in pool if u["duration"] >= 10.0]
|
| 214 |
+
|
| 215 |
+
# Target ratio: 30% short, 50% medium, 20% long
|
| 216 |
+
n_short = max(1, int(samples_per_class * 0.3))
|
| 217 |
+
n_long = max(1, int(samples_per_class * 0.2))
|
| 218 |
+
n_medium = samples_per_class - n_short - n_long
|
| 219 |
+
|
| 220 |
+
chosen = []
|
| 221 |
+
for bucket, n in [(short, n_short), (medium, n_medium), (long, n_long)]:
|
| 222 |
+
rng.shuffle(bucket)
|
| 223 |
+
chosen.extend(bucket[:n])
|
| 224 |
+
|
| 225 |
+
# Fill remaining if any bucket was short
|
| 226 |
+
if len(chosen) < samples_per_class:
|
| 227 |
+
remaining = [u for u in pool if u not in chosen]
|
| 228 |
+
rng.shuffle(remaining)
|
| 229 |
+
chosen.extend(remaining[: samples_per_class - len(chosen)])
|
| 230 |
+
|
| 231 |
+
chosen = chosen[:samples_per_class]
|
| 232 |
+
|
| 233 |
+
selected.extend(chosen)
|
| 234 |
+
stats[emotion] = len(chosen)
|
| 235 |
+
|
| 236 |
+
logger.info("Sampling result: %s (total: %d)", stats, len(selected))
|
| 237 |
+
return selected
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 241 |
+
# Step 3: Extract utterance WAVs
|
| 242 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 243 |
+
|
| 244 |
+
def extract_utterances(
|
| 245 |
+
selected: list[dict],
|
| 246 |
+
output_dir: str,
|
| 247 |
+
) -> list[dict]:
|
| 248 |
+
"""Extract individual utterance WAV segments from conversation files.
|
| 249 |
+
|
| 250 |
+
Reads the stereo WAV, extracts the correct speaker channel,
|
| 251 |
+
and saves as mono 16kHz WAV.
|
| 252 |
+
"""
|
| 253 |
+
out_path = Path(output_dir)
|
| 254 |
+
records = []
|
| 255 |
+
|
| 256 |
+
# Cache loaded audio files (avoid re-reading same WAV)
|
| 257 |
+
audio_cache: dict[str, tuple[np.ndarray, int]] = {}
|
| 258 |
+
|
| 259 |
+
for i, utt in enumerate(selected):
|
| 260 |
+
emotion = utt["emotion"]
|
| 261 |
+
emotion_dir = out_path / "test_audio" / emotion
|
| 262 |
+
emotion_dir.mkdir(parents=True, exist_ok=True)
|
| 263 |
+
|
| 264 |
+
# Load audio (cached)
|
| 265 |
+
wav_path = utt["wav_path"]
|
| 266 |
+
if wav_path not in audio_cache:
|
| 267 |
+
try:
|
| 268 |
+
audio, sr = sf.read(wav_path, dtype="float32")
|
| 269 |
+
audio_cache[wav_path] = (audio, sr)
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.warning("Failed to read %s: %s", wav_path, e)
|
| 272 |
+
continue
|
| 273 |
+
|
| 274 |
+
audio, sr = audio_cache[wav_path]
|
| 275 |
+
|
| 276 |
+
# Extract channel
|
| 277 |
+
if audio.ndim == 2:
|
| 278 |
+
channel = min(utt["channel"], audio.shape[1] - 1)
|
| 279 |
+
mono = audio[:, channel]
|
| 280 |
+
else:
|
| 281 |
+
mono = audio
|
| 282 |
+
|
| 283 |
+
# Extract time range
|
| 284 |
+
start_sample = int(utt["start"] * sr)
|
| 285 |
+
end_sample = int(utt["end"] * sr)
|
| 286 |
+
start_sample = max(0, start_sample)
|
| 287 |
+
end_sample = min(len(mono), end_sample)
|
| 288 |
+
|
| 289 |
+
segment = mono[start_sample:end_sample]
|
| 290 |
+
|
| 291 |
+
if len(segment) < int(MIN_DURATION_SEC * sr):
|
| 292 |
+
logger.warning("Segment too short after extraction: %s_%s", utt["file_stem"], utt["text_no"])
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
# Resample to 16kHz if needed
|
| 296 |
+
if sr != 16000:
|
| 297 |
+
import librosa
|
| 298 |
+
segment = librosa.resample(segment, orig_sr=sr, target_sr=16000)
|
| 299 |
+
sr = 16000
|
| 300 |
+
|
| 301 |
+
# Save
|
| 302 |
+
filename = f"kr_{emotion}_{i:04d}.wav"
|
| 303 |
+
filepath = emotion_dir / filename
|
| 304 |
+
sf.write(str(filepath), segment, 16000, subtype="PCM_16")
|
| 305 |
+
|
| 306 |
+
records.append({
|
| 307 |
+
"file_path": str(filepath.relative_to(out_path)),
|
| 308 |
+
"emotion": emotion,
|
| 309 |
+
"duration": round(len(segment) / 16000, 3),
|
| 310 |
+
"speaker_id": utt["speaker_id"],
|
| 311 |
+
"speaker_gender": utt["speaker_gender"],
|
| 312 |
+
"intensity": utt["intensity"],
|
| 313 |
+
"text": utt["text"],
|
| 314 |
+
"source_file": utt["file_stem"],
|
| 315 |
+
})
|
| 316 |
+
|
| 317 |
+
if (i + 1) % 100 == 0:
|
| 318 |
+
logger.info("Extracted %d/%d utterances", i + 1, len(selected))
|
| 319 |
+
|
| 320 |
+
logger.info("Extracted %d utterance WAVs to %s", len(records), output_dir)
|
| 321 |
+
return records
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 325 |
+
# Step 4: Write labels CSV + metadata JSON
|
| 326 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 327 |
+
|
| 328 |
+
def write_outputs(records: list[dict], output_dir: str, utterances: list[dict]):
|
| 329 |
+
"""Write test_labels.csv and metadata.json."""
|
| 330 |
+
out_path = Path(output_dir)
|
| 331 |
+
|
| 332 |
+
# CSV
|
| 333 |
+
csv_path = out_path / "test_labels.csv"
|
| 334 |
+
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 335 |
+
writer = csv.DictWriter(f, fieldnames=[
|
| 336 |
+
"file_path", "emotion", "duration", "speaker_id",
|
| 337 |
+
"speaker_gender", "intensity", "text", "source_file",
|
| 338 |
+
])
|
| 339 |
+
writer.writeheader()
|
| 340 |
+
writer.writerows(records)
|
| 341 |
+
logger.info("Wrote %s (%d records)", csv_path, len(records))
|
| 342 |
+
|
| 343 |
+
# Metadata
|
| 344 |
+
from collections import Counter
|
| 345 |
+
emotion_dist = Counter(r["emotion"] for r in records)
|
| 346 |
+
duration_stats = [r["duration"] for r in records]
|
| 347 |
+
intensity_dist = Counter(r["intensity"] for r in records)
|
| 348 |
+
|
| 349 |
+
metadata = {
|
| 350 |
+
"dataset": "AI Hub #71631 - ๊ฐ์ ์ด ํ๊น
๋ ์์ ๋ํ (์ฑ์ธ)",
|
| 351 |
+
"subset": "test",
|
| 352 |
+
"total_samples": len(records),
|
| 353 |
+
"eval_classes": EVAL_LABELS,
|
| 354 |
+
"label_mapping": AIHUB_LABEL_MAP,
|
| 355 |
+
"emotion_distribution": dict(emotion_dist),
|
| 356 |
+
"intensity_distribution": dict(intensity_dist),
|
| 357 |
+
"duration_stats": {
|
| 358 |
+
"mean": round(sum(duration_stats) / max(len(duration_stats), 1), 2),
|
| 359 |
+
"min": round(min(duration_stats, default=0), 2),
|
| 360 |
+
"max": round(max(duration_stats, default=0), 2),
|
| 361 |
+
},
|
| 362 |
+
"total_source_utterances": len(utterances),
|
| 363 |
+
"note": "disgust class absent from AI Hub dataset โ 6-class evaluation",
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
meta_path = out_path / "metadata.json"
|
| 367 |
+
with open(meta_path, "w", encoding="utf-8") as f:
|
| 368 |
+
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
| 369 |
+
logger.info("Wrote %s", meta_path)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 373 |
+
# Main
|
| 374 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 375 |
+
|
| 376 |
+
def main():
|
| 377 |
+
parser = argparse.ArgumentParser(
|
| 378 |
+
description="AI Hub ๊ฐ์ ๋ฐ์ดํฐ์
โ ๋ฒค์น๋งํฌ ํ
์คํธ ์๋ธ์
์ค๋น",
|
| 379 |
+
)
|
| 380 |
+
parser.add_argument("--aihub-dir", required=True, help="AI Hub ๋ฐ์ดํฐ ๋ฃจํธ (01.์์ฒ๋ฐ์ดํฐ, 02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ ํฌํจ)")
|
| 381 |
+
parser.add_argument("--output-dir", default="data/evaluation/korean", help="์ถ๋ ฅ ๋๋ ํ ๋ฆฌ")
|
| 382 |
+
parser.add_argument("--samples-per-class", type=int, default=83, help="ํด๋์ค๋น ๋ชฉํ ์ํ ์ (default: 83)")
|
| 383 |
+
parser.add_argument("--seed", type=int, default=42, help="๋๋ค ์๋")
|
| 384 |
+
parser.add_argument("--ground-truth", default="verify", choices=["verify", "speaker"],
|
| 385 |
+
help="Ground truth ์์ค: verify=๊ฒ์ฆ์ ๋ผ๋ฒจ, speaker=ํ์ ์๊ธฐ๋ณด๊ณ ")
|
| 386 |
+
args = parser.parse_args()
|
| 387 |
+
|
| 388 |
+
# 1. Discover pairs
|
| 389 |
+
pairs = discover_pairs(args.aihub_dir)
|
| 390 |
+
if not pairs:
|
| 391 |
+
logger.error("No WAV-JSON pairs found")
|
| 392 |
+
sys.exit(1)
|
| 393 |
+
|
| 394 |
+
# 2. Parse utterances
|
| 395 |
+
utterances = parse_utterances(pairs)
|
| 396 |
+
if not utterances:
|
| 397 |
+
logger.error("No valid utterances parsed")
|
| 398 |
+
sys.exit(1)
|
| 399 |
+
|
| 400 |
+
# Log distribution before sampling
|
| 401 |
+
from collections import Counter
|
| 402 |
+
raw_dist = Counter(u["emotion"] for u in utterances)
|
| 403 |
+
logger.info("Raw distribution: %s", dict(raw_dist))
|
| 404 |
+
|
| 405 |
+
# 3. Balanced sampling
|
| 406 |
+
selected = balanced_sample(utterances, args.samples_per_class, seed=args.seed)
|
| 407 |
+
|
| 408 |
+
# 4. Extract WAVs
|
| 409 |
+
records = extract_utterances(selected, args.output_dir)
|
| 410 |
+
|
| 411 |
+
# 5. Write outputs
|
| 412 |
+
write_outputs(records, args.output_dir, utterances)
|
| 413 |
+
|
| 414 |
+
print(f"\nDone! Test subset ready at {args.output_dir}/")
|
| 415 |
+
print(f" - {len(records)} utterance WAVs in test_audio/")
|
| 416 |
+
print(f" - test_labels.csv")
|
| 417 |
+
print(f" - metadata.json")
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
if __name__ == "__main__":
|
| 421 |
+
main()
|
scripts/prepare_dataset.py
ADDED
|
File without changes
|
scripts/prepare_lora_dataset.py
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Unified data preprocessing pipeline for LoRA emotion2vec 7-class fine-tuning.
|
| 3 |
+
|
| 4 |
+
Extracts, preprocesses, and merges samples from three sources:
|
| 5 |
+
1. AI Hub 263 โ anchor (acted Korean, 7-class)
|
| 6 |
+
2. AI Hub 71631 โ booster (outdoor spontaneous Korean, mapped to 7-class)
|
| 7 |
+
3. RAVDESS โ English (acted, 7-class)
|
| 8 |
+
|
| 9 |
+
Outputs a unified manifest (train/val JSONs) ready for LoRA training.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python scripts/prepare_lora_dataset.py \
|
| 13 |
+
--anchor-dir "data/AI Hub 263" \
|
| 14 |
+
--booster-label-zip "data/AI Hub 71631/01-1.์ ์๊ฐ๋ฐฉ๋ฐ์ดํฐ/Training/02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ/TL_02.์ค์ธ.zip" \
|
| 15 |
+
--booster-audio-zip "data/AI Hub 71631/01-1.์ ์๊ฐ๋ฐฉ๋ฐ์ดํฐ/Training/01.์์ฒ๋ฐ์ดํฐ/TS_02.์ค์ธ.zip" \
|
| 16 |
+
--ravdess-dir data/ravdess \
|
| 17 |
+
--output-dir data/lora_7class
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import csv
|
| 24 |
+
import io
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import random
|
| 28 |
+
import zipfile
|
| 29 |
+
from collections import Counter, defaultdict
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
import torchaudio
|
| 34 |
+
|
| 35 |
+
logging.basicConfig(
|
| 36 |
+
level=logging.INFO,
|
| 37 |
+
format="%(asctime)s %(levelname)-8s %(message)s",
|
| 38 |
+
datefmt="%H:%M:%S",
|
| 39 |
+
)
|
| 40 |
+
log = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Constants
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
LABEL2IDX: dict[str, int] = {
|
| 46 |
+
"happiness": 0,
|
| 47 |
+
"anger": 1,
|
| 48 |
+
"disgust": 2,
|
| 49 |
+
"fear": 3,
|
| 50 |
+
"neutral": 4,
|
| 51 |
+
"sadness": 5,
|
| 52 |
+
"surprise": 6,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
VALID_LABELS = set(LABEL2IDX.keys())
|
| 56 |
+
|
| 57 |
+
TARGET_SR = 16_000
|
| 58 |
+
RMS_THRESHOLD = 0.001 # 0.005โ0.001: disgust ๋ฑ ์ ์๋์ง ๋ฐํ ๋ณด์กด (์ง์ง ๋ฌด์๋ง ์ ๊ฑฐ)
|
| 59 |
+
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
# Task 1 โ Label Mappers
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
_MAP_263: dict[str, str] = {
|
| 65 |
+
"angry": "anger",
|
| 66 |
+
"happiness": "happiness",
|
| 67 |
+
"neutral": "neutral",
|
| 68 |
+
"sadness": "sadness",
|
| 69 |
+
"surprise": "surprise",
|
| 70 |
+
"fear": "fear",
|
| 71 |
+
"disgust": "disgust",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def map_263_label(raw: str) -> str | None:
|
| 76 |
+
"""Map AI Hub 263 annotator label to 7-class. Case-insensitive."""
|
| 77 |
+
if not raw:
|
| 78 |
+
return None
|
| 79 |
+
return _MAP_263.get(raw.strip().lower())
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
_MAP_71631: dict[str, str] = {
|
| 83 |
+
"๊ธฐ์จ": "happiness",
|
| 84 |
+
"ํ๋จ": "anger",
|
| 85 |
+
"๋๋ผ์": "surprise",
|
| 86 |
+
"์ฌํ": "sadness",
|
| 87 |
+
"๋๋ ค์": "fear",
|
| 88 |
+
"์์": "neutral",
|
| 89 |
+
"์ค๋ฆฝ": "neutral",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def map_71631_label(raw: str) -> str | None:
|
| 94 |
+
"""Map AI Hub 71631 Korean emotion label to 7-class."""
|
| 95 |
+
if not raw:
|
| 96 |
+
return None
|
| 97 |
+
return _MAP_71631.get(raw.strip())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def map_ravdess_label(raw: str) -> str | None:
|
| 101 |
+
"""Map RAVDESS label to 7-class. Passthrough except joyโhappiness."""
|
| 102 |
+
if not raw:
|
| 103 |
+
return None
|
| 104 |
+
lbl = raw.strip().lower()
|
| 105 |
+
if lbl == "joy":
|
| 106 |
+
return "happiness"
|
| 107 |
+
if lbl in VALID_LABELS:
|
| 108 |
+
return lbl
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def majority_vote_263(emotions: list[str | None]) -> str | None:
|
| 113 |
+
"""Return majority label (3/5+) or None for no majority/tie."""
|
| 114 |
+
valid = [e for e in emotions if e is not None]
|
| 115 |
+
if not valid:
|
| 116 |
+
return None
|
| 117 |
+
counts = Counter(valid)
|
| 118 |
+
top_label, top_count = counts.most_common(1)[0]
|
| 119 |
+
if top_count < 3:
|
| 120 |
+
return None
|
| 121 |
+
# Check for tie at top count
|
| 122 |
+
tied = [lbl for lbl, c in counts.items() if c == top_count]
|
| 123 |
+
if len(tied) > 1:
|
| 124 |
+
return None
|
| 125 |
+
return top_label
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# Task 1 โ Audio Preprocessing
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
import re
|
| 134 |
+
|
| 135 |
+
def _clean_text(text: str) -> str:
|
| 136 |
+
"""Clean text for STT-friendly format.
|
| 137 |
+
|
| 138 |
+
Removes non-verbal tags like (์์), (ํ์จ), keeps ?, !, ...
|
| 139 |
+
"""
|
| 140 |
+
# Remove non-verbal tags: (์์), (ํ์จ), (์นจ๋ฌต), [noise], etc.
|
| 141 |
+
text = re.sub(r"[(\[๏ผ][^)\]๏ผ]*[)\]๏ผ]", "", text)
|
| 142 |
+
# Remove trailing/leading whitespace, collapse multiple spaces
|
| 143 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 144 |
+
return text
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _compute_rms(waveform: torch.Tensor) -> float:
|
| 148 |
+
"""Compute RMS of a waveform tensor."""
|
| 149 |
+
return float(torch.sqrt(torch.mean(waveform.float() ** 2)))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def preprocess_audio(input_path: Path, output_path: Path) -> bool:
|
| 153 |
+
"""Resample to 16kHz mono, trim silence, reject if RMS < threshold.
|
| 154 |
+
|
| 155 |
+
Returns True if file was saved, False if rejected.
|
| 156 |
+
"""
|
| 157 |
+
input_path = Path(input_path)
|
| 158 |
+
output_path = Path(output_path)
|
| 159 |
+
|
| 160 |
+
waveform, sr = torchaudio.load(str(input_path))
|
| 161 |
+
|
| 162 |
+
# Mono
|
| 163 |
+
if waveform.shape[0] > 1:
|
| 164 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 165 |
+
|
| 166 |
+
# Resample
|
| 167 |
+
if sr != TARGET_SR:
|
| 168 |
+
waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
|
| 169 |
+
|
| 170 |
+
# Trim silence (leading/trailing)
|
| 171 |
+
waveform_trimmed = torchaudio.functional.vad(waveform, TARGET_SR)
|
| 172 |
+
if waveform_trimmed.numel() > 0:
|
| 173 |
+
waveform = waveform_trimmed
|
| 174 |
+
|
| 175 |
+
# RMS check
|
| 176 |
+
rms = _compute_rms(waveform)
|
| 177 |
+
if rms < RMS_THRESHOLD:
|
| 178 |
+
return False
|
| 179 |
+
|
| 180 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 181 |
+
torchaudio.save(str(output_path), waveform, TARGET_SR)
|
| 182 |
+
return True
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def preprocess_audio_from_tensor(
|
| 186 |
+
waveform: torch.Tensor, sr: int, output_path: Path
|
| 187 |
+
) -> bool:
|
| 188 |
+
"""Preprocess an in-memory waveform tensor and save to output_path.
|
| 189 |
+
|
| 190 |
+
Used for 71631 where we slice in memory from the full conversation wav.
|
| 191 |
+
"""
|
| 192 |
+
output_path = Path(output_path)
|
| 193 |
+
|
| 194 |
+
# Mono
|
| 195 |
+
if waveform.dim() > 1 and waveform.shape[0] > 1:
|
| 196 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 197 |
+
elif waveform.dim() == 1:
|
| 198 |
+
waveform = waveform.unsqueeze(0)
|
| 199 |
+
|
| 200 |
+
# Resample
|
| 201 |
+
if sr != TARGET_SR:
|
| 202 |
+
waveform = torchaudio.functional.resample(waveform, sr, TARGET_SR)
|
| 203 |
+
|
| 204 |
+
# Trim silence (leading/trailing) โ same as preprocess_audio
|
| 205 |
+
waveform_trimmed = torchaudio.functional.vad(waveform, TARGET_SR)
|
| 206 |
+
if waveform_trimmed.numel() > 0:
|
| 207 |
+
waveform = waveform_trimmed
|
| 208 |
+
|
| 209 |
+
# RMS check
|
| 210 |
+
rms = _compute_rms(waveform)
|
| 211 |
+
if rms < RMS_THRESHOLD:
|
| 212 |
+
return False
|
| 213 |
+
|
| 214 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 215 |
+
torchaudio.save(str(output_path), waveform, TARGET_SR)
|
| 216 |
+
return True
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
# Task 2 โ AI Hub 263 Extraction
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def parse_263_row(row: list[str]) -> dict | None:
|
| 225 |
+
"""Parse a CSV row from AI Hub 263.
|
| 226 |
+
|
| 227 |
+
Columns: wav_id, ๋ฐํ๋ฌธ, ์ํฉ, 1๋ฒ๊ฐ์ , 1๋ฒ๊ฐ์ ์ธ๊ธฐ, 2๋ฒ๊ฐ์ , 2๋ฒ๊ฐ์ ์ธ๊ธฐ,
|
| 228 |
+
3๋ฒ๊ฐ์ , 3๋ฒ๊ฐ์ ์ธ๊ธฐ, 4๋ฒ๊ฐ์ , 4๋ฒ๊ฐ์ ์ธ๊ธฐ, 5๋ฒ๊ฐ์ , 5๋ฒ๊ฐ์ ์ธ๊ธฐ, ๋์ด, ์ฑ๋ณ
|
| 229 |
+
|
| 230 |
+
Returns dict with wav_id, label, agreement, max_intensity, or None if no majority.
|
| 231 |
+
"""
|
| 232 |
+
if len(row) < 15:
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
wav_id = row[0].strip()
|
| 236 |
+
# Extract 5 annotator emotions and intensities
|
| 237 |
+
annotator_emotions: list[str | None] = []
|
| 238 |
+
intensities: list[int] = []
|
| 239 |
+
for i in range(5):
|
| 240 |
+
emo_col = 3 + i * 2 # 3, 5, 7, 9, 11
|
| 241 |
+
int_col = 3 + i * 2 + 1 # 4, 6, 8, 10, 12
|
| 242 |
+
raw_emo = row[emo_col].strip() if emo_col < len(row) else ""
|
| 243 |
+
raw_int = row[int_col].strip() if int_col < len(row) else "0"
|
| 244 |
+
mapped = map_263_label(raw_emo)
|
| 245 |
+
annotator_emotions.append(mapped)
|
| 246 |
+
try:
|
| 247 |
+
intensities.append(int(raw_int))
|
| 248 |
+
except ValueError:
|
| 249 |
+
intensities.append(0)
|
| 250 |
+
|
| 251 |
+
label = majority_vote_263(annotator_emotions)
|
| 252 |
+
if label is None:
|
| 253 |
+
return None
|
| 254 |
+
|
| 255 |
+
agreement = sum(1 for e in annotator_emotions if e == label)
|
| 256 |
+
# Max intensity among annotators who voted for the majority label
|
| 257 |
+
max_intensity = max(
|
| 258 |
+
(intensities[i] for i, e in enumerate(annotator_emotions) if e == label),
|
| 259 |
+
default=0,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# ๋ฐํ๋ฌธ (text) โ column 1
|
| 263 |
+
text = row[1].strip() if len(row) > 1 else ""
|
| 264 |
+
text = _clean_text(text)
|
| 265 |
+
|
| 266 |
+
return {
|
| 267 |
+
"wav_id": wav_id,
|
| 268 |
+
"text": text,
|
| 269 |
+
"label": label,
|
| 270 |
+
"agreement": agreement,
|
| 271 |
+
"max_intensity": max_intensity,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def extract_anchor_263(
|
| 276 |
+
anchor_dir: Path,
|
| 277 |
+
output_dir: Path,
|
| 278 |
+
cap_per_class: int = 2100,
|
| 279 |
+
) -> list[dict]:
|
| 280 |
+
"""Extract and preprocess AI Hub 263 dataset.
|
| 281 |
+
|
| 282 |
+
Parses 3 CSVs (cp949), majority-votes annotator labels,
|
| 283 |
+
priority-sorts (agreement desc, intensity desc), caps per class,
|
| 284 |
+
extracts wavs from ZIPs, preprocesses audio.
|
| 285 |
+
"""
|
| 286 |
+
anchor_dir = Path(anchor_dir)
|
| 287 |
+
output_dir = Path(output_dir)
|
| 288 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 289 |
+
|
| 290 |
+
csv_files = sorted(anchor_dir.glob("*.csv"))
|
| 291 |
+
zip_files = sorted(anchor_dir.glob("*.zip"))
|
| 292 |
+
|
| 293 |
+
log.info("263: Found %d CSVs, %d ZIPs", len(csv_files), len(zip_files))
|
| 294 |
+
|
| 295 |
+
# Step 1: Parse all CSVs
|
| 296 |
+
all_parsed: list[dict] = []
|
| 297 |
+
for csv_path in csv_files:
|
| 298 |
+
with open(csv_path, encoding="cp949", newline="") as f:
|
| 299 |
+
reader = csv.reader(f)
|
| 300 |
+
header = next(reader) # skip header
|
| 301 |
+
for row in reader:
|
| 302 |
+
result = parse_263_row(row)
|
| 303 |
+
if result is not None:
|
| 304 |
+
result["csv_source"] = csv_path.stem
|
| 305 |
+
all_parsed.append(result)
|
| 306 |
+
|
| 307 |
+
log.info("263: Parsed %d rows with majority vote", len(all_parsed))
|
| 308 |
+
|
| 309 |
+
# Step 2: Group by label, priority sort, cap
|
| 310 |
+
by_label: dict[str, list[dict]] = defaultdict(list)
|
| 311 |
+
for item in all_parsed:
|
| 312 |
+
by_label[item["label"]].append(item)
|
| 313 |
+
|
| 314 |
+
selected: list[dict] = []
|
| 315 |
+
for label, items in by_label.items():
|
| 316 |
+
# Sort by agreement desc, then intensity desc
|
| 317 |
+
items.sort(key=lambda x: (x["agreement"], x["max_intensity"]), reverse=True)
|
| 318 |
+
capped = items[:cap_per_class]
|
| 319 |
+
selected.extend(capped)
|
| 320 |
+
log.info("263: %s โ %d available, %d selected", label, len(items), len(capped))
|
| 321 |
+
|
| 322 |
+
# Step 3: Build wav_id โ zip lookup
|
| 323 |
+
wav_to_zip: dict[str, tuple[zipfile.ZipFile, str]] = {}
|
| 324 |
+
zip_handles = [zipfile.ZipFile(zp) for zp in zip_files]
|
| 325 |
+
for zf in zip_handles:
|
| 326 |
+
for name in zf.namelist():
|
| 327 |
+
if name.endswith(".wav"):
|
| 328 |
+
basename = Path(name).stem
|
| 329 |
+
wav_to_zip[basename] = (zf, name)
|
| 330 |
+
|
| 331 |
+
# Step 4: Extract and preprocess
|
| 332 |
+
samples: list[dict] = []
|
| 333 |
+
skipped = 0
|
| 334 |
+
for item in selected:
|
| 335 |
+
wav_id = item["wav_id"]
|
| 336 |
+
if wav_id not in wav_to_zip:
|
| 337 |
+
skipped += 1
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
zf, zip_entry = wav_to_zip[wav_id]
|
| 341 |
+
out_path = output_dir / item["label"] / f"{wav_id}.wav"
|
| 342 |
+
|
| 343 |
+
try:
|
| 344 |
+
with zf.open(zip_entry) as src:
|
| 345 |
+
audio_bytes = src.read()
|
| 346 |
+
|
| 347 |
+
# Write to temp, then preprocess
|
| 348 |
+
tmp_path = output_dir / f"_tmp_{wav_id}.wav"
|
| 349 |
+
tmp_path.write_bytes(audio_bytes)
|
| 350 |
+
|
| 351 |
+
ok = preprocess_audio(tmp_path, out_path)
|
| 352 |
+
tmp_path.unlink(missing_ok=True)
|
| 353 |
+
|
| 354 |
+
if ok:
|
| 355 |
+
samples.append({
|
| 356 |
+
"path": str(out_path),
|
| 357 |
+
"label": item["label"],
|
| 358 |
+
"label_idx": LABEL2IDX[item["label"]],
|
| 359 |
+
"source": "263",
|
| 360 |
+
"speaker_id": f"263_{wav_id[:8]}",
|
| 361 |
+
"text": item.get("text", ""),
|
| 362 |
+
"agreement": item["agreement"],
|
| 363 |
+
"intensity": item["max_intensity"],
|
| 364 |
+
})
|
| 365 |
+
else:
|
| 366 |
+
skipped += 1
|
| 367 |
+
except Exception as e:
|
| 368 |
+
log.warning("263: Failed to process %s: %s", wav_id, e)
|
| 369 |
+
skipped += 1
|
| 370 |
+
|
| 371 |
+
# Close zip handles
|
| 372 |
+
for zf in zip_handles:
|
| 373 |
+
zf.close()
|
| 374 |
+
|
| 375 |
+
log.info("263: Extracted %d samples, skipped %d", len(samples), skipped)
|
| 376 |
+
return samples
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ---------------------------------------------------------------------------
|
| 380 |
+
# Task 3 โ AI Hub 71631 Outdoor Extraction
|
| 381 |
+
# ---------------------------------------------------------------------------
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def parse_71631_utterance(conv_entry: dict) -> dict | None:
|
| 385 |
+
"""Parse a conversation entry from 71631 JSON.
|
| 386 |
+
|
| 387 |
+
Filters by VerifyEmotionLevel (๋ณดํต/๊ฐํจ only, rejects ์ฝํจ).
|
| 388 |
+
Returns dict with label, intensity, start_time, end_time, speaker_no or None.
|
| 389 |
+
"""
|
| 390 |
+
level = conv_entry.get("VerifyEmotionLevel", "")
|
| 391 |
+
if level not in ("๋ณดํต", "๊ฐํจ"):
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
emotion = conv_entry.get("VerifyEmotionTarget", "")
|
| 395 |
+
label = map_71631_label(emotion)
|
| 396 |
+
if label is None:
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
try:
|
| 400 |
+
start_time = float(conv_entry["StartTime"])
|
| 401 |
+
end_time = float(conv_entry["EndTime"])
|
| 402 |
+
except (KeyError, ValueError):
|
| 403 |
+
return None
|
| 404 |
+
|
| 405 |
+
if end_time <= start_time:
|
| 406 |
+
return None
|
| 407 |
+
|
| 408 |
+
# Text from conversation entry
|
| 409 |
+
text = _clean_text(conv_entry.get("Text", ""))
|
| 410 |
+
|
| 411 |
+
return {
|
| 412 |
+
"label": label,
|
| 413 |
+
"intensity": level,
|
| 414 |
+
"start_time": start_time,
|
| 415 |
+
"end_time": end_time,
|
| 416 |
+
"speaker_no": conv_entry.get("SpeakerNo", ""),
|
| 417 |
+
"text": text,
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def extract_booster_71631(
|
| 422 |
+
label_zip: Path,
|
| 423 |
+
audio_zip: Path,
|
| 424 |
+
output_dir: Path,
|
| 425 |
+
cap: int = 3500,
|
| 426 |
+
max_per_speaker: int = 20,
|
| 427 |
+
) -> list[dict]:
|
| 428 |
+
"""Extract and preprocess AI Hub 71631 outdoor dataset.
|
| 429 |
+
|
| 430 |
+
Parses label ZIP JSONs, filters intensity, applies speaker cap,
|
| 431 |
+
slices wav segments, resamples to 16kHz, RMS-filters neutral.
|
| 432 |
+
"""
|
| 433 |
+
label_zip = Path(label_zip)
|
| 434 |
+
audio_zip = Path(audio_zip)
|
| 435 |
+
output_dir = Path(output_dir)
|
| 436 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 437 |
+
|
| 438 |
+
# Step 1: Parse all label JSONs
|
| 439 |
+
all_utterances: list[dict] = []
|
| 440 |
+
with zipfile.ZipFile(label_zip) as lzf:
|
| 441 |
+
json_files = [n for n in lzf.namelist() if n.endswith(".json")]
|
| 442 |
+
log.info("71631: Found %d label JSONs", len(json_files))
|
| 443 |
+
|
| 444 |
+
for jf in json_files:
|
| 445 |
+
try:
|
| 446 |
+
with lzf.open(jf) as f:
|
| 447 |
+
data = json.load(f)
|
| 448 |
+
except Exception as e:
|
| 449 |
+
log.warning("71631: Failed to parse %s: %s", jf, e)
|
| 450 |
+
continue
|
| 451 |
+
|
| 452 |
+
filename = data.get("File", {}).get("FileName", "")
|
| 453 |
+
conv_id = filename # Use filename as conversation_id
|
| 454 |
+
spk1_id = data.get("Speaker1", {}).get("ID", "")
|
| 455 |
+
spk2_id = data.get("Speaker2", {}).get("ID", "")
|
| 456 |
+
|
| 457 |
+
for entry in data.get("Conversation", []):
|
| 458 |
+
parsed = parse_71631_utterance(entry)
|
| 459 |
+
if parsed is None:
|
| 460 |
+
continue
|
| 461 |
+
# Determine speaker ID
|
| 462 |
+
spk_no = parsed["speaker_no"]
|
| 463 |
+
if spk_no == "Speaker1":
|
| 464 |
+
spk_id = spk1_id
|
| 465 |
+
elif spk_no == "Speaker2":
|
| 466 |
+
spk_id = spk2_id
|
| 467 |
+
else:
|
| 468 |
+
spk_id = spk_no
|
| 469 |
+
|
| 470 |
+
parsed["conversation_id"] = conv_id
|
| 471 |
+
parsed["speaker_id"] = f"71631_{spk_id}"
|
| 472 |
+
parsed["wav_filename"] = filename
|
| 473 |
+
parsed["text_no"] = entry.get("TextNo", "")
|
| 474 |
+
all_utterances.append(parsed)
|
| 475 |
+
|
| 476 |
+
log.info("71631: Parsed %d utterances (๋ณดํต/๊ฐํจ)", len(all_utterances))
|
| 477 |
+
|
| 478 |
+
# Step 2: Speaker cap
|
| 479 |
+
speaker_counts: Counter = Counter()
|
| 480 |
+
speaker_capped: list[dict] = []
|
| 481 |
+
# Priority: ๊ฐํจ first, then ๋ณดํต
|
| 482 |
+
all_utterances.sort(key=lambda x: (0 if x["intensity"] == "๊ฐํจ" else 1))
|
| 483 |
+
for utt in all_utterances:
|
| 484 |
+
spk = utt["speaker_id"]
|
| 485 |
+
if speaker_counts[spk] < max_per_speaker:
|
| 486 |
+
speaker_capped.append(utt)
|
| 487 |
+
speaker_counts[spk] += 1
|
| 488 |
+
|
| 489 |
+
log.info("71631: After speaker cap (%d/spk): %d utterances", max_per_speaker, len(speaker_capped))
|
| 490 |
+
|
| 491 |
+
# Step 3: Group by label, cap per class
|
| 492 |
+
by_label: dict[str, list[dict]] = defaultdict(list)
|
| 493 |
+
for utt in speaker_capped:
|
| 494 |
+
by_label[utt["label"]].append(utt)
|
| 495 |
+
|
| 496 |
+
selected: list[dict] = []
|
| 497 |
+
for label, items in by_label.items():
|
| 498 |
+
# Priority: ๊ฐํจ first (already sorted)
|
| 499 |
+
capped = items[:cap]
|
| 500 |
+
selected.extend(capped)
|
| 501 |
+
log.info("71631: %s โ %d available, %d selected", label, len(items), len(capped))
|
| 502 |
+
|
| 503 |
+
# Step 4: Group by wav filename for efficient audio loading
|
| 504 |
+
by_wav: dict[str, list[dict]] = defaultdict(list)
|
| 505 |
+
for utt in selected:
|
| 506 |
+
by_wav[utt["wav_filename"]].append(utt)
|
| 507 |
+
|
| 508 |
+
# Step 5: Extract audio segments
|
| 509 |
+
samples: list[dict] = []
|
| 510 |
+
skipped = 0
|
| 511 |
+
|
| 512 |
+
with zipfile.ZipFile(audio_zip) as azf:
|
| 513 |
+
wav_lookup: dict[str, str] = {}
|
| 514 |
+
for name in azf.namelist():
|
| 515 |
+
if name.endswith(".wav"):
|
| 516 |
+
stem = Path(name).stem
|
| 517 |
+
wav_lookup[stem] = name
|
| 518 |
+
|
| 519 |
+
for wav_filename, utterances in by_wav.items():
|
| 520 |
+
if wav_filename not in wav_lookup:
|
| 521 |
+
log.warning("71631: WAV not found in zip: %s", wav_filename)
|
| 522 |
+
skipped += len(utterances)
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
zip_entry = wav_lookup[wav_filename]
|
| 526 |
+
try:
|
| 527 |
+
with azf.open(zip_entry) as src:
|
| 528 |
+
audio_bytes = src.read()
|
| 529 |
+
|
| 530 |
+
buf = io.BytesIO(audio_bytes)
|
| 531 |
+
waveform, sr = torchaudio.load(buf)
|
| 532 |
+
except Exception as e:
|
| 533 |
+
log.warning("71631: Failed to load %s: %s", wav_filename, e)
|
| 534 |
+
skipped += len(utterances)
|
| 535 |
+
continue
|
| 536 |
+
|
| 537 |
+
# Mono
|
| 538 |
+
if waveform.shape[0] > 1:
|
| 539 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 540 |
+
|
| 541 |
+
for utt in utterances:
|
| 542 |
+
start_sample = int(utt["start_time"] * sr)
|
| 543 |
+
end_sample = int(utt["end_time"] * sr)
|
| 544 |
+
|
| 545 |
+
if end_sample > waveform.shape[1]:
|
| 546 |
+
end_sample = waveform.shape[1]
|
| 547 |
+
if start_sample >= end_sample:
|
| 548 |
+
skipped += 1
|
| 549 |
+
continue
|
| 550 |
+
|
| 551 |
+
segment = waveform[:, start_sample:end_sample]
|
| 552 |
+
|
| 553 |
+
out_name = f"{wav_filename}_{utt['text_no']}.wav"
|
| 554 |
+
out_path = output_dir / utt["label"] / out_name
|
| 555 |
+
|
| 556 |
+
ok = preprocess_audio_from_tensor(segment, sr, out_path)
|
| 557 |
+
if ok:
|
| 558 |
+
samples.append({
|
| 559 |
+
"path": str(out_path),
|
| 560 |
+
"label": utt["label"],
|
| 561 |
+
"label_idx": LABEL2IDX[utt["label"]],
|
| 562 |
+
"source": "71631",
|
| 563 |
+
"speaker_id": utt["speaker_id"],
|
| 564 |
+
"text": utt.get("text", ""),
|
| 565 |
+
"conversation_id": utt["conversation_id"],
|
| 566 |
+
"intensity": utt["intensity"],
|
| 567 |
+
})
|
| 568 |
+
else:
|
| 569 |
+
skipped += 1
|
| 570 |
+
|
| 571 |
+
log.info("71631: Extracted %d samples, skipped %d", len(samples), skipped)
|
| 572 |
+
return samples
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
# ---------------------------------------------------------------------------
|
| 576 |
+
# Task 4 โ RAVDESS Extraction
|
| 577 |
+
# ---------------------------------------------------------------------------
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def extract_ravdess(ravdess_dir: Path, output_dir: Path) -> list[dict]:
|
| 581 |
+
"""Extract and preprocess RAVDESS dataset from manifest.csv."""
|
| 582 |
+
ravdess_dir = Path(ravdess_dir)
|
| 583 |
+
output_dir = Path(output_dir)
|
| 584 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 585 |
+
|
| 586 |
+
manifest_path = ravdess_dir / "manifest.csv"
|
| 587 |
+
if not manifest_path.exists():
|
| 588 |
+
log.error("RAVDESS manifest not found: %s", manifest_path)
|
| 589 |
+
return []
|
| 590 |
+
|
| 591 |
+
samples: list[dict] = []
|
| 592 |
+
skipped = 0
|
| 593 |
+
|
| 594 |
+
with open(manifest_path) as f:
|
| 595 |
+
reader = csv.DictReader(f)
|
| 596 |
+
for row in reader:
|
| 597 |
+
clean_path = Path(row["clean_path"])
|
| 598 |
+
emotion_raw = row.get("emotion", "")
|
| 599 |
+
label = map_ravdess_label(emotion_raw)
|
| 600 |
+
if label is None:
|
| 601 |
+
skipped += 1
|
| 602 |
+
continue
|
| 603 |
+
|
| 604 |
+
actor_id = int(row["actor_id"])
|
| 605 |
+
out_name = clean_path.name
|
| 606 |
+
out_path = output_dir / label / out_name
|
| 607 |
+
|
| 608 |
+
if not clean_path.exists():
|
| 609 |
+
skipped += 1
|
| 610 |
+
continue
|
| 611 |
+
|
| 612 |
+
ok = preprocess_audio(clean_path, out_path)
|
| 613 |
+
if ok:
|
| 614 |
+
samples.append({
|
| 615 |
+
"path": str(out_path),
|
| 616 |
+
"label": label,
|
| 617 |
+
"label_idx": LABEL2IDX[label],
|
| 618 |
+
"source": "ravdess",
|
| 619 |
+
"actor_id": actor_id,
|
| 620 |
+
"speaker_id": f"ravdess_{actor_id}",
|
| 621 |
+
"text": "", # RAVDESS uses fixed sentences, not useful for text emotion
|
| 622 |
+
})
|
| 623 |
+
else:
|
| 624 |
+
skipped += 1
|
| 625 |
+
|
| 626 |
+
log.info("RAVDESS: Extracted %d samples, skipped %d", len(samples), skipped)
|
| 627 |
+
return samples
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
# ---------------------------------------------------------------------------
|
| 631 |
+
# Task 4 โ Train/Val Splits
|
| 632 |
+
# ---------------------------------------------------------------------------
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def speaker_isolated_split(
|
| 636 |
+
samples: list[dict], val_ratio: float = 0.1
|
| 637 |
+
) -> tuple[list[dict], list[dict]]:
|
| 638 |
+
"""Split by speaker_id โ no leakage between train/val."""
|
| 639 |
+
if not samples:
|
| 640 |
+
return [], []
|
| 641 |
+
|
| 642 |
+
# Group by speaker
|
| 643 |
+
by_speaker: dict[str, list[dict]] = defaultdict(list)
|
| 644 |
+
for s in samples:
|
| 645 |
+
by_speaker[s["speaker_id"]].append(s)
|
| 646 |
+
|
| 647 |
+
speakers = list(by_speaker.keys())
|
| 648 |
+
random.shuffle(speakers)
|
| 649 |
+
|
| 650 |
+
total = len(samples)
|
| 651 |
+
target_val = int(total * val_ratio)
|
| 652 |
+
|
| 653 |
+
val_samples: list[dict] = []
|
| 654 |
+
val_speakers: set[str] = set()
|
| 655 |
+
for spk in speakers:
|
| 656 |
+
if len(val_samples) >= target_val:
|
| 657 |
+
break
|
| 658 |
+
val_samples.extend(by_speaker[spk])
|
| 659 |
+
val_speakers.add(spk)
|
| 660 |
+
|
| 661 |
+
train_samples = [s for s in samples if s["speaker_id"] not in val_speakers]
|
| 662 |
+
return train_samples, val_samples
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
def conversation_isolated_split(
|
| 666 |
+
samples: list[dict], val_ratio: float = 0.1
|
| 667 |
+
) -> tuple[list[dict], list[dict]]:
|
| 668 |
+
"""Split by conversation_id โ no leakage between train/val."""
|
| 669 |
+
if not samples:
|
| 670 |
+
return [], []
|
| 671 |
+
|
| 672 |
+
by_conv: dict[str, list[dict]] = defaultdict(list)
|
| 673 |
+
for s in samples:
|
| 674 |
+
by_conv[s["conversation_id"]].append(s)
|
| 675 |
+
|
| 676 |
+
convs = list(by_conv.keys())
|
| 677 |
+
random.shuffle(convs)
|
| 678 |
+
|
| 679 |
+
total = len(samples)
|
| 680 |
+
target_val = int(total * val_ratio)
|
| 681 |
+
|
| 682 |
+
val_samples: list[dict] = []
|
| 683 |
+
val_convs: set[str] = set()
|
| 684 |
+
for conv in convs:
|
| 685 |
+
if len(val_samples) >= target_val:
|
| 686 |
+
break
|
| 687 |
+
val_samples.extend(by_conv[conv])
|
| 688 |
+
val_convs.add(conv)
|
| 689 |
+
|
| 690 |
+
train_samples = [s for s in samples if s["conversation_id"] not in val_convs]
|
| 691 |
+
return train_samples, val_samples
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def actor_split_ravdess(
|
| 695 |
+
samples: list[dict], val_actors: list[int]
|
| 696 |
+
) -> tuple[list[dict], list[dict]]:
|
| 697 |
+
"""Split RAVDESS by actor โ specified actors go to val."""
|
| 698 |
+
val_set = set(val_actors)
|
| 699 |
+
train = [s for s in samples if s["actor_id"] not in val_set]
|
| 700 |
+
val = [s for s in samples if s["actor_id"] in val_set]
|
| 701 |
+
return train, val
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
# ---------------------------------------------------------------------------
|
| 705 |
+
# Task 4 โ Manifest & Stats
|
| 706 |
+
# ---------------------------------------------------------------------------
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
def save_manifest(samples: list[dict], path: Path) -> None:
|
| 710 |
+
"""Save manifest as JSON Lines."""
|
| 711 |
+
path = Path(path)
|
| 712 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 713 |
+
with open(path, "w") as f:
|
| 714 |
+
json.dump(samples, f, indent=2, ensure_ascii=False)
|
| 715 |
+
log.info("Saved manifest: %s (%d samples)", path, len(samples))
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
def save_stats(train: list[dict], val: list[dict], path: Path) -> None:
|
| 719 |
+
"""Save dataset statistics."""
|
| 720 |
+
path = Path(path)
|
| 721 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 722 |
+
|
| 723 |
+
def _count_stats(samples: list[dict]) -> dict:
|
| 724 |
+
by_label: Counter = Counter()
|
| 725 |
+
by_source: Counter = Counter()
|
| 726 |
+
for s in samples:
|
| 727 |
+
by_label[s["label"]] += 1
|
| 728 |
+
by_source[s["source"]] += 1
|
| 729 |
+
return {
|
| 730 |
+
"total": len(samples),
|
| 731 |
+
"by_label": dict(sorted(by_label.items())),
|
| 732 |
+
"by_source": dict(sorted(by_source.items())),
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
stats = {
|
| 736 |
+
"train": _count_stats(train),
|
| 737 |
+
"val": _count_stats(val),
|
| 738 |
+
"label2idx": LABEL2IDX,
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
with open(path, "w") as f:
|
| 742 |
+
json.dump(stats, f, indent=2, ensure_ascii=False)
|
| 743 |
+
log.info("Saved stats: %s", path)
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
# ---------------------------------------------------------------------------
|
| 747 |
+
# Main
|
| 748 |
+
# ---------------------------------------------------------------------------
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def main():
|
| 752 |
+
parser = argparse.ArgumentParser(
|
| 753 |
+
description="Prepare unified LoRA 7-class dataset"
|
| 754 |
+
)
|
| 755 |
+
parser.add_argument(
|
| 756 |
+
"--anchor-dir",
|
| 757 |
+
type=Path,
|
| 758 |
+
default=Path("data/AI Hub 263"),
|
| 759 |
+
help="Path to AI Hub 263 directory with CSVs + ZIPs",
|
| 760 |
+
)
|
| 761 |
+
parser.add_argument(
|
| 762 |
+
"--booster-label-zip",
|
| 763 |
+
type=Path,
|
| 764 |
+
default=Path(
|
| 765 |
+
"data/AI Hub 71631/01-1.์ ์๊ฐ๋ฐฉ๋ฐ์ดํฐ/Training/"
|
| 766 |
+
"02.๋ผ๋ฒจ๋ง๋ฐ์ดํฐ/TL_02.์ค์ธ.zip"
|
| 767 |
+
),
|
| 768 |
+
help="Path to 71631 label ZIP",
|
| 769 |
+
)
|
| 770 |
+
parser.add_argument(
|
| 771 |
+
"--booster-audio-zip",
|
| 772 |
+
type=Path,
|
| 773 |
+
default=Path(
|
| 774 |
+
"data/AI Hub 71631/01-1.์ ์๊ฐ๋ฐฉ๋ฐ์ดํฐ/Training/"
|
| 775 |
+
"01.์์ฒ๋ฐ์ดํฐ/TS_02.์ค์ธ.zip"
|
| 776 |
+
),
|
| 777 |
+
help="Path to 71631 audio ZIP",
|
| 778 |
+
)
|
| 779 |
+
parser.add_argument(
|
| 780 |
+
"--ravdess-dir",
|
| 781 |
+
type=Path,
|
| 782 |
+
default=Path("data/ravdess"),
|
| 783 |
+
help="Path to RAVDESS directory with manifest.csv",
|
| 784 |
+
)
|
| 785 |
+
parser.add_argument(
|
| 786 |
+
"--output-dir",
|
| 787 |
+
type=Path,
|
| 788 |
+
default=Path("data/lora_7class"),
|
| 789 |
+
help="Output directory for processed dataset",
|
| 790 |
+
)
|
| 791 |
+
parser.add_argument("--cap-263", type=int, default=2100, help="Cap per class for 263")
|
| 792 |
+
parser.add_argument("--cap-71631", type=int, default=3500, help="Cap per class for 71631")
|
| 793 |
+
parser.add_argument("--max-per-speaker-71631", type=int, default=20, help="Max utterances per speaker for 71631")
|
| 794 |
+
parser.add_argument("--val-ratio", type=float, default=0.1, help="Val ratio for splits")
|
| 795 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed")
|
| 796 |
+
parser.add_argument(
|
| 797 |
+
"--skip-263", action="store_true", help="Skip AI Hub 263 extraction"
|
| 798 |
+
)
|
| 799 |
+
parser.add_argument(
|
| 800 |
+
"--skip-71631", action="store_true", help="Skip AI Hub 71631 extraction"
|
| 801 |
+
)
|
| 802 |
+
parser.add_argument(
|
| 803 |
+
"--skip-ravdess", action="store_true", help="Skip RAVDESS extraction"
|
| 804 |
+
)
|
| 805 |
+
args = parser.parse_args()
|
| 806 |
+
|
| 807 |
+
random.seed(args.seed)
|
| 808 |
+
|
| 809 |
+
output_dir = args.output_dir
|
| 810 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 811 |
+
|
| 812 |
+
all_train: list[dict] = []
|
| 813 |
+
all_val: list[dict] = []
|
| 814 |
+
|
| 815 |
+
# ---- 263 Anchor ----
|
| 816 |
+
if not args.skip_263:
|
| 817 |
+
log.info("=" * 60)
|
| 818 |
+
log.info("Extracting AI Hub 263 (anchor)")
|
| 819 |
+
samples_263 = extract_anchor_263(
|
| 820 |
+
args.anchor_dir,
|
| 821 |
+
output_dir / "263",
|
| 822 |
+
cap_per_class=args.cap_263,
|
| 823 |
+
)
|
| 824 |
+
train_263, val_263 = speaker_isolated_split(samples_263, args.val_ratio)
|
| 825 |
+
log.info("263: train=%d, val=%d", len(train_263), len(val_263))
|
| 826 |
+
all_train.extend(train_263)
|
| 827 |
+
all_val.extend(val_263)
|
| 828 |
+
|
| 829 |
+
# ---- 71631 Booster ----
|
| 830 |
+
if not args.skip_71631:
|
| 831 |
+
log.info("=" * 60)
|
| 832 |
+
log.info("Extracting AI Hub 71631 (booster)")
|
| 833 |
+
samples_71631 = extract_booster_71631(
|
| 834 |
+
args.booster_label_zip,
|
| 835 |
+
args.booster_audio_zip,
|
| 836 |
+
output_dir / "71631",
|
| 837 |
+
cap=args.cap_71631,
|
| 838 |
+
max_per_speaker=args.max_per_speaker_71631,
|
| 839 |
+
)
|
| 840 |
+
train_71631, val_71631 = conversation_isolated_split(
|
| 841 |
+
samples_71631, args.val_ratio
|
| 842 |
+
)
|
| 843 |
+
log.info("71631: train=%d, val=%d", len(train_71631), len(val_71631))
|
| 844 |
+
all_train.extend(train_71631)
|
| 845 |
+
all_val.extend(val_71631)
|
| 846 |
+
|
| 847 |
+
# ---- RAVDESS ----
|
| 848 |
+
if not args.skip_ravdess:
|
| 849 |
+
log.info("=" * 60)
|
| 850 |
+
log.info("Extracting RAVDESS")
|
| 851 |
+
samples_ravdess = extract_ravdess(
|
| 852 |
+
args.ravdess_dir,
|
| 853 |
+
output_dir / "ravdess",
|
| 854 |
+
)
|
| 855 |
+
val_actors = [21, 22, 23, 24]
|
| 856 |
+
train_ravdess, val_ravdess = actor_split_ravdess(
|
| 857 |
+
samples_ravdess, val_actors
|
| 858 |
+
)
|
| 859 |
+
log.info("RAVDESS: train=%d, val=%d", len(train_ravdess), len(val_ravdess))
|
| 860 |
+
all_train.extend(train_ravdess)
|
| 861 |
+
all_val.extend(val_ravdess)
|
| 862 |
+
|
| 863 |
+
# ---- Save ----
|
| 864 |
+
log.info("=" * 60)
|
| 865 |
+
log.info("Total: train=%d, val=%d", len(all_train), len(all_val))
|
| 866 |
+
|
| 867 |
+
save_manifest(all_train, output_dir / "train_manifest.json")
|
| 868 |
+
save_manifest(all_val, output_dir / "val_manifest.json")
|
| 869 |
+
save_stats(all_train, all_val, output_dir / "stats.json")
|
| 870 |
+
|
| 871 |
+
log.info("Done! Output: %s", output_dir)
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
if __name__ == "__main__":
|
| 875 |
+
main()
|
scripts/prepare_meld_fusion_data.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Prepare MELD test split for English fusion grid search.
|
| 3 |
+
|
| 4 |
+
Extracts mp4 โ wav (16kHz mono) and builds a manifest with text + emotion labels.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/prepare_meld_fusion_data.py
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import csv
|
| 12 |
+
import io
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import subprocess
|
| 16 |
+
import tempfile
|
| 17 |
+
import zipfile
|
| 18 |
+
from collections import Counter
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 25 |
+
|
| 26 |
+
# MELD emotions map 1:1 to project labels
|
| 27 |
+
MELD_LABEL_MAP = {
|
| 28 |
+
"neutral": "neutral",
|
| 29 |
+
"joy": "joy",
|
| 30 |
+
"sadness": "sadness",
|
| 31 |
+
"anger": "anger",
|
| 32 |
+
"surprise": "surprise",
|
| 33 |
+
"fear": "fear",
|
| 34 |
+
"disgust": "disgust",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
zip_path = Path("data/english_test.zip")
|
| 40 |
+
output_dir = Path("data/meld_fusion")
|
| 41 |
+
audio_dir = output_dir / "audio"
|
| 42 |
+
audio_dir.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
zf = zipfile.ZipFile(zip_path)
|
| 45 |
+
|
| 46 |
+
# Step 1: Parse test CSV
|
| 47 |
+
logger.info("Parsing MELD test CSV...")
|
| 48 |
+
with zf.open("MELD.Raw/MELD.Raw/test_sent_emo.csv") as f:
|
| 49 |
+
reader = csv.DictReader(io.TextIOWrapper(f, encoding="utf-8"))
|
| 50 |
+
rows = list(reader)
|
| 51 |
+
logger.info("MELD test: %d utterances", len(rows))
|
| 52 |
+
|
| 53 |
+
# Build lookup: (dia_id, utt_id) โ row
|
| 54 |
+
csv_lookup = {}
|
| 55 |
+
for r in rows:
|
| 56 |
+
key = (int(r["Dialogue_ID"]), int(r["Utterance_ID"]))
|
| 57 |
+
csv_lookup[key] = r
|
| 58 |
+
|
| 59 |
+
# Step 2: Find mp4 files in zip
|
| 60 |
+
test_mp4s = {}
|
| 61 |
+
for name in zf.namelist():
|
| 62 |
+
if "output_repeated_splits_test" in name and name.endswith(".mp4"):
|
| 63 |
+
fname = Path(name).name
|
| 64 |
+
if fname.startswith("._"):
|
| 65 |
+
continue # skip macOS metadata
|
| 66 |
+
# Parse dia{D}_utt{U}.mp4
|
| 67 |
+
try:
|
| 68 |
+
parts = fname.replace(".mp4", "").split("_")
|
| 69 |
+
dia_id = int(parts[0].replace("dia", ""))
|
| 70 |
+
utt_id = int(parts[1].replace("utt", ""))
|
| 71 |
+
test_mp4s[(dia_id, utt_id)] = name
|
| 72 |
+
except (ValueError, IndexError):
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
logger.info("Found %d test mp4 files (excluding macOS metadata)", len(test_mp4s))
|
| 76 |
+
|
| 77 |
+
# Step 3: Match CSV โ mp4, extract wav
|
| 78 |
+
manifest = []
|
| 79 |
+
skipped = 0
|
| 80 |
+
|
| 81 |
+
matched_keys = set(csv_lookup.keys()) & set(test_mp4s.keys())
|
| 82 |
+
logger.info("Matched CSVโmp4: %d", len(matched_keys))
|
| 83 |
+
|
| 84 |
+
for i, key in enumerate(sorted(matched_keys)):
|
| 85 |
+
row = csv_lookup[key]
|
| 86 |
+
mp4_name = test_mp4s[key]
|
| 87 |
+
dia_id, utt_id = key
|
| 88 |
+
|
| 89 |
+
label = MELD_LABEL_MAP.get(row["Emotion"])
|
| 90 |
+
if label is None:
|
| 91 |
+
skipped += 1
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
text = row["Utterance"].strip()
|
| 95 |
+
if not text:
|
| 96 |
+
skipped += 1
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
wav_path = audio_dir / f"dia{dia_id}_utt{utt_id}.wav"
|
| 100 |
+
|
| 101 |
+
if not wav_path.exists():
|
| 102 |
+
# Extract mp4 from zip โ convert to 16kHz mono wav
|
| 103 |
+
try:
|
| 104 |
+
mp4_bytes = zf.read(mp4_name)
|
| 105 |
+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp:
|
| 106 |
+
tmp.write(mp4_bytes)
|
| 107 |
+
tmp_path = tmp.name
|
| 108 |
+
|
| 109 |
+
result = subprocess.run(
|
| 110 |
+
["ffmpeg", "-y", "-i", tmp_path,
|
| 111 |
+
"-ar", "16000", "-ac", "1", "-f", "wav",
|
| 112 |
+
str(wav_path)],
|
| 113 |
+
capture_output=True, timeout=30,
|
| 114 |
+
)
|
| 115 |
+
Path(tmp_path).unlink(missing_ok=True)
|
| 116 |
+
|
| 117 |
+
if result.returncode != 0:
|
| 118 |
+
skipped += 1
|
| 119 |
+
continue
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.warning("Failed dia%d_utt%d: %s", dia_id, utt_id, e)
|
| 122 |
+
skipped += 1
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
manifest.append({
|
| 126 |
+
"path": str(wav_path),
|
| 127 |
+
"text": text,
|
| 128 |
+
"label": label,
|
| 129 |
+
"source": "meld_test",
|
| 130 |
+
"dialogue_id": dia_id,
|
| 131 |
+
"utterance_id": utt_id,
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
if (i + 1) % 200 == 0:
|
| 135 |
+
logger.info("Processed %d / %d", i + 1, len(matched_keys))
|
| 136 |
+
|
| 137 |
+
zf.close()
|
| 138 |
+
|
| 139 |
+
# Step 4: Save manifest
|
| 140 |
+
manifest_path = output_dir / "manifest.json"
|
| 141 |
+
with open(manifest_path, "w", encoding="utf-8") as f:
|
| 142 |
+
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
| 143 |
+
|
| 144 |
+
logger.info("Saved %d samples to %s (skipped %d)", len(manifest), manifest_path, skipped)
|
| 145 |
+
|
| 146 |
+
# Stats
|
| 147 |
+
emotions = Counter(s["label"] for s in manifest)
|
| 148 |
+
for e, c in sorted(emotions.items(), key=lambda x: -x[1]):
|
| 149 |
+
print(f" {e}: {c}")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
if __name__ == "__main__":
|
| 153 |
+
main()
|
scripts/prepare_ravdess.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""RAVDESS ์์ด ๊ฐ์ ์์ฑ ๋ฐ์ดํฐ์
์ค๋น ์คํฌ๋ฆฝํธ.
|
| 3 |
+
|
| 4 |
+
data/archive.zip์ ์์ถ ํด์ ํ๊ณ , ์ ํ ํ์ง ์ ์ฒ๋ฆฌ๋ฅผ ์ ์ฉํ์ฌ
|
| 5 |
+
emotion2vec ์์ด ํ๊ฐ์ฉ manifest.csv๋ฅผ ์์ฑํ๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/prepare_ravdess.py
|
| 9 |
+
python scripts/prepare_ravdess.py --skip-phone # ์ ํ ์ ์ฒ๋ฆฌ ์๋ต
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import csv
|
| 16 |
+
import logging
|
| 17 |
+
import sys
|
| 18 |
+
import zipfile
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import librosa
|
| 22 |
+
import numpy as np
|
| 23 |
+
import soundfile as sf
|
| 24 |
+
|
| 25 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 26 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 27 |
+
|
| 28 |
+
from src.common.phone_simulator import CompandingType, PhoneSimulator
|
| 29 |
+
|
| 30 |
+
logging.basicConfig(
|
| 31 |
+
level=logging.INFO,
|
| 32 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 33 |
+
)
|
| 34 |
+
logger = logging.getLogger("prepare_ravdess")
|
| 35 |
+
|
| 36 |
+
# RAVDESS emotion code โ project 7-class taxonomy
|
| 37 |
+
RAVDESS_EMOTION_MAP = {
|
| 38 |
+
1: "neutral", # neutral
|
| 39 |
+
2: "neutral", # calm โ neutral (ํ๋ก์ ํธ taxonomy์ calm ์์)
|
| 40 |
+
3: "joy", # happy โ joy
|
| 41 |
+
4: "sadness", # sad โ sadness
|
| 42 |
+
5: "anger", # angry โ anger
|
| 43 |
+
6: "fear", # fearful โ fear
|
| 44 |
+
7: "disgust", # disgust
|
| 45 |
+
8: "surprise", # surprised โ surprise
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
RAVDESS_EMOTION_NAME = {
|
| 49 |
+
1: "neutral", 2: "calm", 3: "happy", 4: "sad",
|
| 50 |
+
5: "angry", 6: "fearful", 7: "disgust", 8: "surprised",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
ARCHIVE_PATH = PROJECT_ROOT / "data" / "archive.zip"
|
| 54 |
+
OUTPUT_DIR = PROJECT_ROOT / "data" / "ravdess"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def parse_ravdess_filename(filename: str) -> dict | None:
|
| 58 |
+
"""RAVDESS ํ์ผ๋ช
์์ ๋ฉํ๋ฐ์ดํฐ ์ถ์ถ.
|
| 59 |
+
|
| 60 |
+
Format: Modality-VocalChannel-Emotion-Intensity-Statement-Repetition-Actor.wav
|
| 61 |
+
Example: 03-01-05-02-01-01-12.wav
|
| 62 |
+
"""
|
| 63 |
+
stem = Path(filename).stem
|
| 64 |
+
parts = stem.split("-")
|
| 65 |
+
if len(parts) != 7:
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
emotion_code = int(parts[2])
|
| 69 |
+
return {
|
| 70 |
+
"modality": int(parts[0]),
|
| 71 |
+
"vocal_channel": int(parts[1]),
|
| 72 |
+
"emotion_code": emotion_code,
|
| 73 |
+
"emotion_raw": RAVDESS_EMOTION_NAME.get(emotion_code, "unknown"),
|
| 74 |
+
"emotion": RAVDESS_EMOTION_MAP.get(emotion_code, "neutral"),
|
| 75 |
+
"intensity": int(parts[3]), # 1=normal, 2=strong
|
| 76 |
+
"statement": int(parts[4]), # 1="Kids...", 2="Dogs..."
|
| 77 |
+
"repetition": int(parts[5]),
|
| 78 |
+
"actor_id": int(parts[6]),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def extract_archive(archive_path: Path, output_dir: Path) -> list[Path]:
|
| 83 |
+
"""archive.zip ์์ถ ํด์ โ clean/ ๋๋ ํ ๋ฆฌ."""
|
| 84 |
+
clean_dir = output_dir / "clean"
|
| 85 |
+
|
| 86 |
+
if clean_dir.exists() and any(clean_dir.rglob("*.wav")):
|
| 87 |
+
wavs = sorted(clean_dir.rglob("*.wav"))
|
| 88 |
+
logger.info(f"์ด๋ฏธ ์์ถ ํด์ ๋จ: {len(wavs)}๊ฐ WAV in {clean_dir}")
|
| 89 |
+
return wavs
|
| 90 |
+
|
| 91 |
+
clean_dir.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
logger.info(f"์์ถ ํด์ ์ค: {archive_path} โ {clean_dir}")
|
| 93 |
+
|
| 94 |
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
| 95 |
+
wav_members = [m for m in zf.namelist() if m.endswith(".wav")]
|
| 96 |
+
for i, member in enumerate(wav_members, 1):
|
| 97 |
+
# Actor_NN/filename.wav โ clean/Actor_NN/filename.wav
|
| 98 |
+
target = clean_dir / member
|
| 99 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
with zf.open(member) as src, open(target, "wb") as dst:
|
| 101 |
+
dst.write(src.read())
|
| 102 |
+
if i % 500 == 0:
|
| 103 |
+
logger.info(f" [{i}/{len(wav_members)}] ์์ถ ํด์ ์ค...")
|
| 104 |
+
|
| 105 |
+
wavs = sorted(clean_dir.rglob("*.wav"))
|
| 106 |
+
logger.info(f"์์ถ ํด์ ์๋ฃ: {len(wavs)}๊ฐ WAV")
|
| 107 |
+
return wavs
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def apply_phone_simulation(clean_wavs: list[Path], output_dir: Path) -> dict[str, Path]:
|
| 111 |
+
"""clean WAV โ phone ํ์ง ๋ณํ. {clean_path_str: phone_path} ๋ฐํ."""
|
| 112 |
+
phone_dir = output_dir / "phone"
|
| 113 |
+
simulator = PhoneSimulator(companding=CompandingType.ULAW) # ์์ด = ๋ถ๋ฏธ ฮผ-law
|
| 114 |
+
|
| 115 |
+
mapping = {}
|
| 116 |
+
total = len(clean_wavs)
|
| 117 |
+
|
| 118 |
+
for i, wav_path in enumerate(clean_wavs, 1):
|
| 119 |
+
# clean/Actor_NN/file.wav โ phone/Actor_NN/file.wav
|
| 120 |
+
relative = wav_path.relative_to(output_dir / "clean")
|
| 121 |
+
phone_path = phone_dir / relative
|
| 122 |
+
phone_path.parent.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
|
| 124 |
+
if phone_path.exists():
|
| 125 |
+
mapping[str(wav_path)] = phone_path
|
| 126 |
+
continue
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
audio, sr = librosa.load(str(wav_path), sr=None, mono=True)
|
| 130 |
+
processed, new_sr = simulator.process(audio, sr)
|
| 131 |
+
sf.write(str(phone_path), processed, new_sr, subtype="PCM_16")
|
| 132 |
+
mapping[str(wav_path)] = phone_path
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.warning(f"์ ํ ๋ณํ ์คํจ [{wav_path.name}]: {e}")
|
| 135 |
+
|
| 136 |
+
if i % 500 == 0:
|
| 137 |
+
logger.info(f" [{i}/{total}] ์ ํ ํ์ง ๋ณํ ์ค...")
|
| 138 |
+
|
| 139 |
+
logger.info(f"์ ํ ํ์ง ๋ณํ ์๋ฃ: {len(mapping)}/{total}")
|
| 140 |
+
return mapping
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def build_manifest(
|
| 144 |
+
clean_wavs: list[Path],
|
| 145 |
+
phone_mapping: dict[str, Path] | None,
|
| 146 |
+
output_dir: Path,
|
| 147 |
+
) -> Path:
|
| 148 |
+
"""manifest.csv ์์ฑ."""
|
| 149 |
+
manifest_path = output_dir / "manifest.csv"
|
| 150 |
+
rows = []
|
| 151 |
+
|
| 152 |
+
for wav_path in clean_wavs:
|
| 153 |
+
meta = parse_ravdess_filename(wav_path.name)
|
| 154 |
+
if meta is None:
|
| 155 |
+
logger.warning(f"ํ์ผ๋ช
ํ์ฑ ์คํจ: {wav_path.name}")
|
| 156 |
+
continue
|
| 157 |
+
|
| 158 |
+
phone_path = ""
|
| 159 |
+
if phone_mapping and str(wav_path) in phone_mapping:
|
| 160 |
+
phone_path = str(phone_mapping[str(wav_path)])
|
| 161 |
+
|
| 162 |
+
rows.append({
|
| 163 |
+
"clean_path": str(wav_path),
|
| 164 |
+
"phone_path": phone_path,
|
| 165 |
+
"emotion": meta["emotion"],
|
| 166 |
+
"emotion_raw": meta["emotion_raw"],
|
| 167 |
+
"actor_id": meta["actor_id"],
|
| 168 |
+
"intensity": meta["intensity"],
|
| 169 |
+
"statement": meta["statement"],
|
| 170 |
+
"repetition": meta["repetition"],
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
# ๊ฐ์ ๋ณ ํต๊ณ ์ถ๋ ฅ
|
| 174 |
+
from collections import Counter
|
| 175 |
+
emotion_counts = Counter(r["emotion"] for r in rows)
|
| 176 |
+
logger.info("๊ฐ์ ๋ถํฌ:")
|
| 177 |
+
for emotion, count in sorted(emotion_counts.items()):
|
| 178 |
+
logger.info(f" {emotion}: {count}")
|
| 179 |
+
|
| 180 |
+
with open(manifest_path, "w", newline="") as f:
|
| 181 |
+
writer = csv.DictWriter(f, fieldnames=[
|
| 182 |
+
"clean_path", "phone_path", "emotion", "emotion_raw",
|
| 183 |
+
"actor_id", "intensity", "statement", "repetition",
|
| 184 |
+
])
|
| 185 |
+
writer.writeheader()
|
| 186 |
+
writer.writerows(rows)
|
| 187 |
+
|
| 188 |
+
logger.info(f"manifest ์ ์ฅ: {manifest_path} ({len(rows)}ํ)")
|
| 189 |
+
return manifest_path
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def main():
|
| 193 |
+
parser = argparse.ArgumentParser(description="RAVDESS ์์ด ๊ฐ์ ๋ฐ์ดํฐ ์ค๋น")
|
| 194 |
+
parser.add_argument("--skip-phone", action="store_true", help="์ ํ ํ์ง ์ ์ฒ๋ฆฌ ์๋ต")
|
| 195 |
+
args = parser.parse_args()
|
| 196 |
+
|
| 197 |
+
if not ARCHIVE_PATH.exists():
|
| 198 |
+
logger.error(f"archive.zip์ ์ฐพ์ ์ ์์ต๋๋ค: {ARCHIVE_PATH}")
|
| 199 |
+
sys.exit(1)
|
| 200 |
+
|
| 201 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 202 |
+
|
| 203 |
+
# 1. ์์ถ ํด์
|
| 204 |
+
clean_wavs = extract_archive(ARCHIVE_PATH, OUTPUT_DIR)
|
| 205 |
+
|
| 206 |
+
# 2. ์ ํ ํ์ง ์ ์ฒ๋ฆฌ
|
| 207 |
+
phone_mapping = None
|
| 208 |
+
if not args.skip_phone:
|
| 209 |
+
phone_mapping = apply_phone_simulation(clean_wavs, OUTPUT_DIR)
|
| 210 |
+
else:
|
| 211 |
+
logger.info("์ ํ ํ์ง ์ ์ฒ๋ฆฌ ์๋ต (--skip-phone)")
|
| 212 |
+
|
| 213 |
+
# 3. manifest.csv ์์ฑ
|
| 214 |
+
build_manifest(clean_wavs, phone_mapping, OUTPUT_DIR)
|
| 215 |
+
|
| 216 |
+
logger.info("์๋ฃ!")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
main()
|
scripts/preprocess_phone_audio.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""AI Hub ๋ฑ ์คํ๋์ค ๋
น์ ๋ฐ์ดํฐ๋ฅผ ์ ํ ํตํ ํ์ง๋ก ์ ์ฒ๋ฆฌํ๋ ์คํฌ๋ฆฝํธ.
|
| 3 |
+
|
| 4 |
+
๊นจ๋ํ ์ค๋์ค์ PSTN ์๋ฎฌ๋ ์ด์
(๋ฐด๋ํจ์ค + ๋ค์ด์ํ๋ง + G.711 companding)์ ์ ์ฉํ์ฌ
|
| 5 |
+
์ค์ ํตํ ๋
น์๊ณผ ์ ์ฌํ ํ์ต ๋ฐ์ดํฐ๋ฅผ ์์ฑํ๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
# ๋จ์ผ ํ์ผ
|
| 9 |
+
python scripts/preprocess_phone_audio.py data/aihub_raw/sample.wav
|
| 10 |
+
|
| 11 |
+
# ๋๋ ํ ๋ฆฌ ์ผ๊ด ์ฒ๋ฆฌ
|
| 12 |
+
python scripts/preprocess_phone_audio.py data/aihub_raw/ -o data/aihub_phone/
|
| 13 |
+
|
| 14 |
+
# companding ๋ฐฉ์ ์ง์ (๊ธฐ๋ณธ: random)
|
| 15 |
+
python scripts/preprocess_phone_audio.py data/aihub_raw/ --companding alaw
|
| 16 |
+
|
| 17 |
+
# ์๋ณธ๋ ํจ๊ป ๋ณต์ฌ (์๋ณธ+์ ํ ํผํฉ ํ์ต์ฉ)
|
| 18 |
+
python scripts/preprocess_phone_audio.py data/aihub_raw/ -o data/training/ --keep-original
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import logging
|
| 25 |
+
import shutil
|
| 26 |
+
import sys
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import librosa
|
| 30 |
+
import soundfile as sf
|
| 31 |
+
|
| 32 |
+
# ํ๋ก์ ํธ ๋ฃจํธ๋ฅผ sys.path์ ์ถ๊ฐ
|
| 33 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 34 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 35 |
+
|
| 36 |
+
from src.common.phone_simulator import CompandingType, PhoneSimulator
|
| 37 |
+
|
| 38 |
+
logging.basicConfig(
|
| 39 |
+
level=logging.INFO,
|
| 40 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 41 |
+
)
|
| 42 |
+
logger = logging.getLogger("preprocess_phone_audio")
|
| 43 |
+
|
| 44 |
+
SUPPORTED_EXTENSIONS = {".wav", ".mp3", ".m4a", ".ogg", ".flac"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def find_audio_files(input_path: Path) -> list[Path]:
|
| 48 |
+
"""์
๋ ฅ ๊ฒฝ๋ก์์ ์ค๋์ค ํ์ผ ๋ชฉ๋ก ๋ฐํ."""
|
| 49 |
+
if input_path.is_file():
|
| 50 |
+
if input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
|
| 51 |
+
return [input_path]
|
| 52 |
+
logger.warning(f"์ง์ํ์ง ์๋ ํ์ผ ํ์: {input_path.suffix}")
|
| 53 |
+
return []
|
| 54 |
+
|
| 55 |
+
files = []
|
| 56 |
+
for ext in SUPPORTED_EXTENSIONS:
|
| 57 |
+
files.extend(input_path.rglob(f"*{ext}"))
|
| 58 |
+
return sorted(files)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def process_file(
|
| 62 |
+
input_file: Path,
|
| 63 |
+
output_dir: Path,
|
| 64 |
+
simulator: PhoneSimulator,
|
| 65 |
+
input_root: Path,
|
| 66 |
+
keep_original: bool = False,
|
| 67 |
+
) -> bool:
|
| 68 |
+
"""๋จ์ผ ํ์ผ์ ์ ํ ํ์ง๋ก ๋ณํ."""
|
| 69 |
+
try:
|
| 70 |
+
# ์๋ณธ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์ ์ง
|
| 71 |
+
relative = input_file.relative_to(input_root)
|
| 72 |
+
output_file = output_dir / relative.with_suffix(".wav")
|
| 73 |
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 74 |
+
|
| 75 |
+
# ์ค๋์ค ๋ก๋ (mono, ์๋ณธ SR ์ ์ง)
|
| 76 |
+
audio, sr = librosa.load(str(input_file), sr=None, mono=True)
|
| 77 |
+
|
| 78 |
+
# ์ ํ ํ์ง ์๋ฎฌ๋ ์ด์
์ ์ฉ
|
| 79 |
+
processed, new_sr = simulator.process(audio, sr)
|
| 80 |
+
|
| 81 |
+
# phone_ ์ ๋์ฌ๋ก ์ ์ฅ
|
| 82 |
+
phone_output = output_file.with_name(f"phone_{output_file.name}")
|
| 83 |
+
sf.write(str(phone_output), processed, new_sr, subtype="PCM_16")
|
| 84 |
+
|
| 85 |
+
# ์๋ณธ๋ ๋ณต์ฌ (ํผํฉ ํ์ต์ฉ)
|
| 86 |
+
if keep_original:
|
| 87 |
+
orig_output = output_file.with_name(f"orig_{output_file.name}")
|
| 88 |
+
shutil.copy2(str(input_file), str(orig_output))
|
| 89 |
+
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"์ฒ๋ฆฌ ์คํจ [{input_file.name}]: {e}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main():
|
| 98 |
+
parser = argparse.ArgumentParser(
|
| 99 |
+
description="์คํ๋์ค ๋
น์ โ ์ ํ ํตํ ํ์ง ์ ์ฒ๋ฆฌ",
|
| 100 |
+
)
|
| 101 |
+
parser.add_argument(
|
| 102 |
+
"input",
|
| 103 |
+
type=Path,
|
| 104 |
+
help="์
๋ ฅ ์ค๋์ค ํ์ผ ๋๋ ๋๋ ํ ๋ฆฌ",
|
| 105 |
+
)
|
| 106 |
+
parser.add_argument(
|
| 107 |
+
"-o", "--output",
|
| 108 |
+
type=Path,
|
| 109 |
+
default=None,
|
| 110 |
+
help="์ถ๋ ฅ ๋๋ ํ ๋ฆฌ (๊ธฐ๋ณธ: {input}_phone/)",
|
| 111 |
+
)
|
| 112 |
+
parser.add_argument(
|
| 113 |
+
"--companding",
|
| 114 |
+
type=str,
|
| 115 |
+
choices=["alaw", "ulaw", "random"],
|
| 116 |
+
default="random",
|
| 117 |
+
help="G.711 companding ๋ฐฉ์ (๊ธฐ๋ณธ: random โ ํ์ผ๋ง๋ค ๋๋ค ์ ํ)",
|
| 118 |
+
)
|
| 119 |
+
parser.add_argument(
|
| 120 |
+
"--keep-original",
|
| 121 |
+
action="store_true",
|
| 122 |
+
help="์๋ณธ ํ์ผ๋ ์ถ๋ ฅ ๋๋ ํ ๋ฆฌ์ ๋ณต์ฌ (์๋ณธ+์ ํ ํผํฉ ํ์ต์ฉ)",
|
| 123 |
+
)
|
| 124 |
+
args = parser.parse_args()
|
| 125 |
+
|
| 126 |
+
# ์
๋ ฅ ๊ฒฝ๋ก ํ์ธ
|
| 127 |
+
input_path = args.input.resolve()
|
| 128 |
+
if not input_path.exists():
|
| 129 |
+
logger.error(f"์
๋ ฅ ๊ฒฝ๋ก๊ฐ ์กด์ฌํ์ง ์์ต๋๋ค: {input_path}")
|
| 130 |
+
sys.exit(1)
|
| 131 |
+
|
| 132 |
+
# ์ถ๋ ฅ ๋๋ ํ ๋ฆฌ ๊ฒฐ์
|
| 133 |
+
if args.output:
|
| 134 |
+
output_dir = args.output.resolve()
|
| 135 |
+
else:
|
| 136 |
+
if input_path.is_file():
|
| 137 |
+
output_dir = input_path.parent / f"{input_path.stem}_phone"
|
| 138 |
+
else:
|
| 139 |
+
output_dir = input_path.parent / f"{input_path.name}_phone"
|
| 140 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
|
| 142 |
+
# ์
๋ ฅ ๋ฃจํธ (์๋ ๊ฒฝ๋ก ๊ณ์ฐ์ฉ)
|
| 143 |
+
input_root = input_path if input_path.is_dir() else input_path.parent
|
| 144 |
+
|
| 145 |
+
# ์ค๋์ค ํ์ผ ํ์
|
| 146 |
+
audio_files = find_audio_files(input_path)
|
| 147 |
+
if not audio_files:
|
| 148 |
+
logger.error("์ฒ๋ฆฌํ ์ค๋์ค ํ์ผ์ด ์์ต๋๋ค.")
|
| 149 |
+
sys.exit(1)
|
| 150 |
+
|
| 151 |
+
logger.info(f"์ค๋์ค ํ์ผ {len(audio_files)}๊ฐ ๋ฐ๊ฒฌ")
|
| 152 |
+
logger.info(f"์ถ๋ ฅ ๋๋ ํ ๋ฆฌ: {output_dir}")
|
| 153 |
+
logger.info(f"Companding: {args.companding}")
|
| 154 |
+
if args.keep_original:
|
| 155 |
+
logger.info("์๋ณธ ํ์ผ๋ ํจ๊ป ๋ณต์ฌํฉ๋๋ค")
|
| 156 |
+
|
| 157 |
+
# ์๋ฎฌ๋ ์ดํฐ ์์ฑ
|
| 158 |
+
companding = CompandingType(args.companding)
|
| 159 |
+
simulator = PhoneSimulator(companding=companding)
|
| 160 |
+
|
| 161 |
+
# ์ผ๊ด ์ฒ๋ฆฌ
|
| 162 |
+
success = 0
|
| 163 |
+
fail = 0
|
| 164 |
+
for i, audio_file in enumerate(audio_files, 1):
|
| 165 |
+
logger.info(f"[{i}/{len(audio_files)}] {audio_file.name}")
|
| 166 |
+
if process_file(audio_file, output_dir, simulator, input_root, args.keep_original):
|
| 167 |
+
success += 1
|
| 168 |
+
else:
|
| 169 |
+
fail += 1
|
| 170 |
+
|
| 171 |
+
logger.info(f"์๋ฃ: ์ฑ๊ณต {success}, ์คํจ {fail}, ์ ์ฒด {len(audio_files)}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|
scripts/quantize_model.py
ADDED
|
File without changes
|
scripts/run_pipeline.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Stage 1 โ Stage 2 E2E ํ์ดํ๋ผ์ธ ์คํ ์คํฌ๋ฆฝํธ.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/run_pipeline.py # ๊ธฐ๋ณธ ์ํ ์ฌ์ฉ
|
| 6 |
+
python scripts/run_pipeline.py data/samples/my_call.wav # ํน์ ํ์ผ ์ง์
|
| 7 |
+
python scripts/run_pipeline.py --stage2-only # Stage 2๋ง ์คํ (stage1_output.json ํ์)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# ํ๋ก์ ํธ ๋ฃจํธ๋ฅผ sys.path์ ์ถ๊ฐ
|
| 19 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 20 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 21 |
+
|
| 22 |
+
logging.basicConfig(
|
| 23 |
+
level=logging.INFO,
|
| 24 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger("run_pipeline")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_stage1(audio_path: str) -> dict:
|
| 30 |
+
"""Stage 1 ์คํ: ํ์๋ถ๋ฆฌ + ASR."""
|
| 31 |
+
from src.stage1.process import process as stage1_process
|
| 32 |
+
|
| 33 |
+
logger.info("=" * 60)
|
| 34 |
+
logger.info("Stage 1 ์์: %s", audio_path)
|
| 35 |
+
logger.info("=" * 60)
|
| 36 |
+
|
| 37 |
+
result = stage1_process(audio_path)
|
| 38 |
+
|
| 39 |
+
logger.info(
|
| 40 |
+
"Stage 1 ์๋ฃ: %d segments, %.1fs ์ฒ๋ฆฌ์๊ฐ",
|
| 41 |
+
len(result.segments),
|
| 42 |
+
result.processing_info.processing_time_sec,
|
| 43 |
+
)
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def run_stage2(stage1_output) -> dict:
|
| 48 |
+
"""Stage 2 ์คํ: ๊ฐ์ ๋ถ์."""
|
| 49 |
+
from src.stage2.process import process as stage2_process
|
| 50 |
+
|
| 51 |
+
logger.info("=" * 60)
|
| 52 |
+
logger.info("Stage 2 ์์: %s (%d segments)", stage1_output.call_id, len(stage1_output.segments))
|
| 53 |
+
logger.info("=" * 60)
|
| 54 |
+
|
| 55 |
+
result = stage2_process(stage1_output)
|
| 56 |
+
|
| 57 |
+
logger.info(
|
| 58 |
+
"Stage 2 ์๋ฃ: %d emotions, speakers=%s",
|
| 59 |
+
len(result.emotions),
|
| 60 |
+
list(result.speaker_summaries.keys()),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# ๊ฒฐ๊ณผ ์์ฝ ์ถ๋ ฅ
|
| 64 |
+
for speaker_id, summary in result.speaker_summaries.items():
|
| 65 |
+
logger.info(
|
| 66 |
+
" %s: dominant=%s (%.1f%%), avg_confidence=%.2f",
|
| 67 |
+
speaker_id,
|
| 68 |
+
summary.dominant_emotion,
|
| 69 |
+
summary.emotion_distribution.get(summary.dominant_emotion, 0) * 100,
|
| 70 |
+
summary.avg_confidence,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
return result
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main():
|
| 77 |
+
parser = argparse.ArgumentParser(description="Stage 1 โ Stage 2 ํ์ดํ๋ผ์ธ ์คํ")
|
| 78 |
+
parser.add_argument(
|
| 79 |
+
"audio_path",
|
| 80 |
+
nargs="?",
|
| 81 |
+
default="data/samples/sample_data.wav",
|
| 82 |
+
help="์
๋ ฅ ์ค๋์ค ํ์ผ ๊ฒฝ๋ก (๊ธฐ๋ณธ: data/samples/sample_data.wav)",
|
| 83 |
+
)
|
| 84 |
+
parser.add_argument(
|
| 85 |
+
"--stage2-only",
|
| 86 |
+
action="store_true",
|
| 87 |
+
help="Stage 2๋ง ์คํ (data/stage1_output.json ํ์)",
|
| 88 |
+
)
|
| 89 |
+
args = parser.parse_args()
|
| 90 |
+
|
| 91 |
+
if args.stage2_only:
|
| 92 |
+
# Stage 2๋ง ์คํ
|
| 93 |
+
from src.common.schemas import Stage1Output
|
| 94 |
+
|
| 95 |
+
stage1_path = PROJECT_ROOT / "data" / "stage1_output.json"
|
| 96 |
+
if not stage1_path.exists():
|
| 97 |
+
logger.error("data/stage1_output.json ์์. Stage 1์ ๋จผ์ ์คํํ์ธ์.")
|
| 98 |
+
sys.exit(1)
|
| 99 |
+
|
| 100 |
+
stage1_output = Stage1Output.model_validate_json(stage1_path.read_text())
|
| 101 |
+
run_stage2(stage1_output)
|
| 102 |
+
else:
|
| 103 |
+
# Stage 1 โ Stage 2 ์ ์ฒด ์คํ
|
| 104 |
+
audio_path = str(PROJECT_ROOT / args.audio_path) if not Path(args.audio_path).is_absolute() else args.audio_path
|
| 105 |
+
|
| 106 |
+
if not Path(audio_path).exists():
|
| 107 |
+
logger.error("์ค๋์ค ํ์ผ ์์: %s", audio_path)
|
| 108 |
+
sys.exit(1)
|
| 109 |
+
|
| 110 |
+
stage1_output = run_stage1(audio_path)
|
| 111 |
+
run_stage2(stage1_output)
|
| 112 |
+
|
| 113 |
+
logger.info("=" * 60)
|
| 114 |
+
logger.info("ํ์ดํ๋ผ์ธ ์๋ฃ!")
|
| 115 |
+
logger.info(" Stage 1 ์ถ๋ ฅ: data/stage1_output.json")
|
| 116 |
+
logger.info(" Stage 2 ์ถ๋ ฅ: data/stage2_output.json")
|
| 117 |
+
logger.info("=" * 60)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
main()
|
scripts/test_20hours_e2e_server.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""20Hours Korean demo set โ E2E server test.
|
| 3 |
+
|
| 4 |
+
Uploads 7 curated Korean demo WAVs to the deployed HF Spaces server,
|
| 5 |
+
runs the full pipeline (Stage 1 โ 2 โ 3), and compares results
|
| 6 |
+
against the intended demo emotion labels (data/20hours_test/ground_truth.json).
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python scripts/test_20hours_e2e_server.py
|
| 10 |
+
python scripts/test_20hours_e2e_server.py --server http://localhost:8000
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import requests
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 24 |
+
TEST_DIR = PROJECT_ROOT / "data" / "20hours_test"
|
| 25 |
+
GT_PATH = TEST_DIR / "ground_truth.json"
|
| 26 |
+
|
| 27 |
+
DEFAULT_SERVER = "https://bbbakery-ustwo-api.hf.space"
|
| 28 |
+
POLL_INTERVAL = 5
|
| 29 |
+
MAX_WAIT = 300
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def health_check(base: str) -> bool:
|
| 33 |
+
try:
|
| 34 |
+
r = requests.get(f"{base}/api/health", timeout=10)
|
| 35 |
+
data = r.json()
|
| 36 |
+
if data.get("status") == "ok":
|
| 37 |
+
print(f" Server OK ({data.get('timestamp', '?')})")
|
| 38 |
+
return True
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f" Health check failed: {e}")
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def upload(base: str, wav_path: Path) -> str | None:
|
| 45 |
+
with open(wav_path, "rb") as f:
|
| 46 |
+
r = requests.post(
|
| 47 |
+
f"{base}/api/upload",
|
| 48 |
+
files={"file": (wav_path.name, f, "audio/wav")},
|
| 49 |
+
timeout=60,
|
| 50 |
+
)
|
| 51 |
+
if r.status_code != 200:
|
| 52 |
+
print(f" Upload failed ({r.status_code}): {r.text[:200]}")
|
| 53 |
+
return None
|
| 54 |
+
return r.json().get("call_id")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def analyze_and_poll(base: str, call_id: str) -> dict | None:
|
| 58 |
+
r = requests.post(f"{base}/api/analyze", params={"call_id": call_id}, timeout=30)
|
| 59 |
+
if r.status_code not in (200, 202):
|
| 60 |
+
print(f" Analyze start failed ({r.status_code}): {r.text[:200]}")
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
data = r.json()
|
| 64 |
+
if data.get("status") == "done":
|
| 65 |
+
return data.get("result")
|
| 66 |
+
|
| 67 |
+
elapsed = 0
|
| 68 |
+
while elapsed < MAX_WAIT:
|
| 69 |
+
time.sleep(POLL_INTERVAL)
|
| 70 |
+
elapsed += POLL_INTERVAL
|
| 71 |
+
r = requests.get(f"{base}/api/analyze/{call_id}/status", timeout=15)
|
| 72 |
+
data = r.json()
|
| 73 |
+
status = data.get("status")
|
| 74 |
+
if status == "done":
|
| 75 |
+
return data.get("result")
|
| 76 |
+
if status == "error":
|
| 77 |
+
print(f" Pipeline error: {data.get('error', '?')}")
|
| 78 |
+
return None
|
| 79 |
+
mins, secs = divmod(elapsed, 60)
|
| 80 |
+
print(f" {status}... ({int(mins)}m{int(secs)}s)", end="\r")
|
| 81 |
+
|
| 82 |
+
print(f" Timeout after {MAX_WAIT}s")
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def extract_emotions(result: dict) -> dict:
|
| 87 |
+
info: dict = {}
|
| 88 |
+
reactions = result.get("character_reactions", [])
|
| 89 |
+
for i, rx in enumerate(reactions):
|
| 90 |
+
info[f"speaker_{i}_state"] = rx.get("solo_state", "?")
|
| 91 |
+
garden = result.get("garden_update", {})
|
| 92 |
+
info["garden_mood"] = garden.get("mood", "?")
|
| 93 |
+
info["garden_delta"] = garden.get("growth_delta", 0)
|
| 94 |
+
recap = result.get("recap_card", {}) or {}
|
| 95 |
+
info["recap_headline"] = recap.get("headline") or recap.get("title", "?")
|
| 96 |
+
|
| 97 |
+
stage2 = result.get("stage2_output", {})
|
| 98 |
+
if stage2:
|
| 99 |
+
for spk, summary in stage2.get("speaker_summaries", {}).items():
|
| 100 |
+
info[f"{spk}_dominant"] = summary.get("dominant_emotion", "?")
|
| 101 |
+
info[f"{spk}_distribution"] = summary.get("emotion_distribution", {})
|
| 102 |
+
|
| 103 |
+
# Segment-level language breakdown
|
| 104 |
+
emotions = result.get("emotions") or stage2.get("emotions", [])
|
| 105 |
+
segs_by_lang: dict[str, int] = {}
|
| 106 |
+
for e in emotions:
|
| 107 |
+
lang = e.get("language") or "?"
|
| 108 |
+
segs_by_lang[lang] = segs_by_lang.get(lang, 0) + 1
|
| 109 |
+
info["segments"] = len(emotions)
|
| 110 |
+
info["segments_by_lang"] = segs_by_lang
|
| 111 |
+
return info
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
parser = argparse.ArgumentParser()
|
| 116 |
+
parser.add_argument("--server", default=DEFAULT_SERVER)
|
| 117 |
+
args = parser.parse_args()
|
| 118 |
+
base = args.server.rstrip("/")
|
| 119 |
+
|
| 120 |
+
print("=" * 70)
|
| 121 |
+
print(" 20Hours Korean Demo โ E2E Server Test")
|
| 122 |
+
print(f" Server: {base}")
|
| 123 |
+
print("=" * 70)
|
| 124 |
+
|
| 125 |
+
print("\n[1] Health check")
|
| 126 |
+
if not health_check(base):
|
| 127 |
+
sys.exit(1)
|
| 128 |
+
|
| 129 |
+
print("\n[2] Loading intended emotion labels")
|
| 130 |
+
gt = json.loads(GT_PATH.read_text())
|
| 131 |
+
print(f" {len(gt)} demo clips loaded")
|
| 132 |
+
|
| 133 |
+
print("\n[3] Running E2E tests\n")
|
| 134 |
+
results = {}
|
| 135 |
+
hit = 0
|
| 136 |
+
total = 0
|
| 137 |
+
|
| 138 |
+
for tag in sorted(gt.keys()):
|
| 139 |
+
wav_path = TEST_DIR / f"{tag}.wav"
|
| 140 |
+
if not wav_path.exists():
|
| 141 |
+
print(f" {tag}: WAV not found, skipping")
|
| 142 |
+
continue
|
| 143 |
+
gt_entry = gt[tag]
|
| 144 |
+
print(f" {tag} โ {gt_entry['description'][:55]}")
|
| 145 |
+
print(f" Intended: {gt_entry['primary_emotion']} | Duration: {gt_entry['duration_sec']}s | Utts: {gt_entry['total_utterances']}")
|
| 146 |
+
|
| 147 |
+
call_id = upload(base, wav_path)
|
| 148 |
+
if not call_id:
|
| 149 |
+
results[tag] = {"status": "upload_failed"}
|
| 150 |
+
continue
|
| 151 |
+
print(f" Upload OK โ {call_id}")
|
| 152 |
+
|
| 153 |
+
print(f" Analyzing...", end="")
|
| 154 |
+
start_time = time.time()
|
| 155 |
+
result = analyze_and_poll(base, call_id)
|
| 156 |
+
elapsed = time.time() - start_time
|
| 157 |
+
|
| 158 |
+
if not result:
|
| 159 |
+
results[tag] = {"status": "analyze_failed", "call_id": call_id}
|
| 160 |
+
print()
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
print(f"\r Done in {elapsed:.1f}s ")
|
| 164 |
+
|
| 165 |
+
emotions = extract_emotions(result)
|
| 166 |
+
total += 1
|
| 167 |
+
intended = gt_entry["primary_emotion"]
|
| 168 |
+
speaker_states = {k: v for k, v in emotions.items() if k.endswith("_state")}
|
| 169 |
+
if intended in speaker_states.values():
|
| 170 |
+
hit += 1
|
| 171 |
+
match = "HIT"
|
| 172 |
+
else:
|
| 173 |
+
match = "miss"
|
| 174 |
+
|
| 175 |
+
results[tag] = {
|
| 176 |
+
"status": "pass",
|
| 177 |
+
"call_id": call_id,
|
| 178 |
+
"elapsed_sec": round(elapsed, 1),
|
| 179 |
+
"intended_emotion": intended,
|
| 180 |
+
"match": match,
|
| 181 |
+
"emotions": emotions,
|
| 182 |
+
"full_result": result,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
for k, v in emotions.items():
|
| 186 |
+
if not k.endswith("_distribution"):
|
| 187 |
+
print(f" {k}: {v}")
|
| 188 |
+
else:
|
| 189 |
+
dist = ", ".join(f"{kk}:{vv:.2f}" for kk, vv in sorted(v.items(), key=lambda x: -x[1])[:3])
|
| 190 |
+
print(f" {k}: {dist}")
|
| 191 |
+
print(f" โ {match}")
|
| 192 |
+
print()
|
| 193 |
+
|
| 194 |
+
out_path = TEST_DIR / "e2e_results.json"
|
| 195 |
+
save = {}
|
| 196 |
+
for tag, r in results.items():
|
| 197 |
+
save[tag] = {k: v for k, v in r.items() if k != "full_result"}
|
| 198 |
+
out_path.write_text(json.dumps(save, indent=2, ensure_ascii=False))
|
| 199 |
+
|
| 200 |
+
print("=" * 70)
|
| 201 |
+
print(" SUMMARY")
|
| 202 |
+
print("=" * 70)
|
| 203 |
+
print(f"\n {'Tag':<24} {'Intended':<12} {'Match':<6} {'Time':>6}")
|
| 204 |
+
print(" " + "-" * 55)
|
| 205 |
+
for tag in sorted(results.keys()):
|
| 206 |
+
r = results[tag]
|
| 207 |
+
if r.get("status") != "pass":
|
| 208 |
+
continue
|
| 209 |
+
intended = r["intended_emotion"]
|
| 210 |
+
m = r["match"]
|
| 211 |
+
t = f"{r['elapsed_sec']:.0f}s"
|
| 212 |
+
print(f" {tag:<24} {intended:<12} {m:<6} {t:>6}")
|
| 213 |
+
print(f"\n Intended-emotion match: {hit}/{total}")
|
| 214 |
+
print(f" Results saved: {out_path}")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
if __name__ == "__main__":
|
| 218 |
+
main()
|
scripts/test_english_e2e.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""์์ด E2E ํ์ดํ๋ผ์ธ ํ
์คํธ.
|
| 3 |
+
|
| 4 |
+
RAVDESS WAV + ์๋ ์์ฑ ํ
์คํธ๋ก Stage2 ์ ์ฒด ํ์ดํ๋ผ์ธ์ ํ
์คํธํ๋ค.
|
| 5 |
+
๊ฐ์ ๋ณ 2๊ฐ์ฉ 14๊ฐ ์ธ๊ทธ๋จผํธ๋ฅผ ํต๊ณผ์์ผ audio + text + fusion ๋์์ ๊ฒ์ฆ.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/test_english_e2e.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import csv
|
| 14 |
+
import json
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 20 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 21 |
+
|
| 22 |
+
logging.basicConfig(
|
| 23 |
+
level=logging.INFO,
|
| 24 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 25 |
+
)
|
| 26 |
+
logger = logging.getLogger("test_english_e2e")
|
| 27 |
+
|
| 28 |
+
MANIFEST_PATH = PROJECT_ROOT / "data" / "ravdess" / "manifest.csv"
|
| 29 |
+
|
| 30 |
+
# ๊ฐ์ ๋ณ ๋งค์นญ ํ
์คํธ (RAVDESS๋ ํ
์คํธ ์์ผ๋ฏ๋ก ์๋ ์์ฑ)
|
| 31 |
+
EMOTION_TEXTS = {
|
| 32 |
+
"neutral": [
|
| 33 |
+
"The meeting is scheduled for three o'clock.",
|
| 34 |
+
"I'll pick up the groceries on the way home.",
|
| 35 |
+
],
|
| 36 |
+
"joy": [
|
| 37 |
+
"I'm so happy to hear from you!",
|
| 38 |
+
"That's wonderful news, I'm thrilled!",
|
| 39 |
+
],
|
| 40 |
+
"sadness": [
|
| 41 |
+
"I miss you so much, it hurts.",
|
| 42 |
+
"I feel really down today, nothing is going right.",
|
| 43 |
+
],
|
| 44 |
+
"anger": [
|
| 45 |
+
"I can't believe you did that, I'm so angry!",
|
| 46 |
+
"This is completely unacceptable, stop it now!",
|
| 47 |
+
],
|
| 48 |
+
"surprise": [
|
| 49 |
+
"Oh my god, I can't believe it!",
|
| 50 |
+
"What?! I never expected that!",
|
| 51 |
+
],
|
| 52 |
+
"fear": [
|
| 53 |
+
"I'm scared, something doesn't feel right.",
|
| 54 |
+
"Please help, I'm terrified right now.",
|
| 55 |
+
],
|
| 56 |
+
"disgust": [
|
| 57 |
+
"That's absolutely disgusting, I feel sick.",
|
| 58 |
+
"This is revolting, I can't stand it.",
|
| 59 |
+
],
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def pick_samples(manifest_path: Path) -> list[dict]:
|
| 64 |
+
"""๊ฐ์ ๋ณ 2๊ฐ์ฉ WAV ์ ํ."""
|
| 65 |
+
by_emotion: dict[str, list[dict]] = {}
|
| 66 |
+
with open(manifest_path) as f:
|
| 67 |
+
for row in csv.DictReader(f):
|
| 68 |
+
emotion = row["emotion"]
|
| 69 |
+
if emotion not in by_emotion:
|
| 70 |
+
by_emotion[emotion] = []
|
| 71 |
+
if len(by_emotion[emotion]) < 2:
|
| 72 |
+
by_emotion[emotion].append(row)
|
| 73 |
+
|
| 74 |
+
samples = []
|
| 75 |
+
for emotion in EMOTION_TEXTS:
|
| 76 |
+
if emotion in by_emotion:
|
| 77 |
+
samples.extend(by_emotion[emotion])
|
| 78 |
+
return samples
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def run_e2e():
|
| 82 |
+
"""E2E ํ
์คํธ ์คํ."""
|
| 83 |
+
from src.common.schemas import (
|
| 84 |
+
Models,
|
| 85 |
+
ProcessingInfo,
|
| 86 |
+
Segment,
|
| 87 |
+
Stage1Output,
|
| 88 |
+
)
|
| 89 |
+
from src.stage2.process import process
|
| 90 |
+
|
| 91 |
+
if not MANIFEST_PATH.exists():
|
| 92 |
+
logger.error("manifest.csv๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. ๋จผ์ prepare_ravdess.py๋ฅผ ์คํํ์ธ์.")
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
samples = pick_samples(MANIFEST_PATH)
|
| 96 |
+
logger.info(f"์ ํ๋ ํ
์คํธ ์ํ: {len(samples)}๊ฐ")
|
| 97 |
+
|
| 98 |
+
# Stage1Output ์์ฑ
|
| 99 |
+
segments = []
|
| 100 |
+
ground_truths = []
|
| 101 |
+
for i, sample in enumerate(samples):
|
| 102 |
+
emotion = sample["emotion"]
|
| 103 |
+
texts = EMOTION_TEXTS[emotion]
|
| 104 |
+
text = texts[i % len(texts)]
|
| 105 |
+
|
| 106 |
+
segments.append(Segment(
|
| 107 |
+
segment_id=i,
|
| 108 |
+
speaker_id=f"speaker_{int(sample['actor_id']) % 2}",
|
| 109 |
+
start=float(i * 3.0),
|
| 110 |
+
end=float(i * 3.0 + 3.0),
|
| 111 |
+
text=text,
|
| 112 |
+
language="en",
|
| 113 |
+
audio_path=sample["clean_path"],
|
| 114 |
+
confidence=0.95,
|
| 115 |
+
))
|
| 116 |
+
ground_truths.append(emotion)
|
| 117 |
+
|
| 118 |
+
stage1_output = Stage1Output(
|
| 119 |
+
call_id="ravdess_e2e_test",
|
| 120 |
+
duration=float(len(segments) * 3.0),
|
| 121 |
+
speakers=["speaker_0", "speaker_1"],
|
| 122 |
+
audio_path="data/ravdess/test_call.wav",
|
| 123 |
+
segments=segments,
|
| 124 |
+
processing_info=ProcessingInfo(
|
| 125 |
+
processing_time_sec=1.0,
|
| 126 |
+
models=Models(
|
| 127 |
+
diarization="pyannote/speaker-diarization-3.1",
|
| 128 |
+
asr="large-v3-turbo",
|
| 129 |
+
language_id="whisper",
|
| 130 |
+
alignment="whisperx",
|
| 131 |
+
),
|
| 132 |
+
device="cpu",
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Stage2 config (์์ด ์ ์ฉ)
|
| 137 |
+
config = {
|
| 138 |
+
"audio_emotion": {
|
| 139 |
+
"model": "iic/emotion2vec_plus_base",
|
| 140 |
+
},
|
| 141 |
+
"text_emotion": {
|
| 142 |
+
"korean_model": "searle-j/kote_for_easygoing_people",
|
| 143 |
+
"english_model": "j-hartmann/emotion-english-distilroberta-base",
|
| 144 |
+
},
|
| 145 |
+
"fusion": {
|
| 146 |
+
"audio_weight": 0.6,
|
| 147 |
+
"text_weight": 0.4,
|
| 148 |
+
},
|
| 149 |
+
"output_path": "data/e2e_test_output.json",
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
logger.info("Stage 2 process() ์คํ ์ค...")
|
| 153 |
+
output = process(stage1_output, config=config)
|
| 154 |
+
|
| 155 |
+
# ๊ฒ์ฆ
|
| 156 |
+
logger.info("\n" + "=" * 70)
|
| 157 |
+
logger.info("E2E ํ
์คํธ ๊ฒฐ๊ณผ")
|
| 158 |
+
logger.info("=" * 70)
|
| 159 |
+
|
| 160 |
+
errors = []
|
| 161 |
+
|
| 162 |
+
# 1. EmotionResult ๊ฐ์ ํ์ธ
|
| 163 |
+
if len(output.emotions) != len(segments):
|
| 164 |
+
errors.append(f"EmotionResult ๊ฐ์ ๋ถ์ผ์น: {len(output.emotions)} != {len(segments)}")
|
| 165 |
+
else:
|
| 166 |
+
logger.info(f"[PASS] EmotionResult ๊ฐ์: {len(output.emotions)}")
|
| 167 |
+
|
| 168 |
+
# 2. SpeakerSummary ํ์ธ
|
| 169 |
+
if len(output.speaker_summaries) > 0:
|
| 170 |
+
logger.info(f"[PASS] SpeakerSummary ์์ฑ: {len(output.speaker_summaries)}๋ช
")
|
| 171 |
+
else:
|
| 172 |
+
errors.append("SpeakerSummary๊ฐ ๋น์ด์์")
|
| 173 |
+
|
| 174 |
+
# 3. ๊ฐ EmotionResult ์ ํจ์ฑ ํ์ธ
|
| 175 |
+
valid_emotions = {"neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"}
|
| 176 |
+
for em in output.emotions:
|
| 177 |
+
if em.fused_emotion not in valid_emotions:
|
| 178 |
+
errors.append(f"์ ํจํ์ง ์์ fused_emotion: {em.fused_emotion}")
|
| 179 |
+
if not (0.0 <= em.fused_confidence <= 1.0):
|
| 180 |
+
errors.append(f"fused_confidence ๋ฒ์ ์ด๊ณผ: {em.fused_confidence}")
|
| 181 |
+
|
| 182 |
+
if not any("์ ํจํ์ง ์์" in e for e in errors):
|
| 183 |
+
logger.info("[PASS] ๋ชจ๋ EmotionResult ์ ํจ")
|
| 184 |
+
|
| 185 |
+
# 4. JSON ์ง๋ ฌํ ํ
์คํธ
|
| 186 |
+
try:
|
| 187 |
+
json_str = output.model_dump_json(indent=2)
|
| 188 |
+
roundtrip = json.loads(json_str)
|
| 189 |
+
logger.info(f"[PASS] JSON ์ง๋ ฌํ/์ญ์ง๋ ฌํ ์ฑ๊ณต ({len(json_str)} bytes)")
|
| 190 |
+
except Exception as e:
|
| 191 |
+
errors.append(f"JSON ์ง๋ ฌํ ์คํจ: {e}")
|
| 192 |
+
|
| 193 |
+
# 5. ๊ฒฐ๊ณผ ํ
์ด๋ธ
|
| 194 |
+
logger.info(f"\n{'Seg':>3s} | {'Ground Truth':>12s} | {'Audio':>10s} | {'Text':>10s} | {'Fused':>10s} | {'Conf':>5s}")
|
| 195 |
+
logger.info("-" * 65)
|
| 196 |
+
audio_correct = 0
|
| 197 |
+
text_correct = 0
|
| 198 |
+
fused_correct = 0
|
| 199 |
+
for i, (em, gt) in enumerate(zip(output.emotions, ground_truths)):
|
| 200 |
+
a_match = "o" if em.audio_emotion == gt else "x"
|
| 201 |
+
t_match = "o" if em.text_emotion == gt else "x"
|
| 202 |
+
f_match = "o" if em.fused_emotion == gt else "x"
|
| 203 |
+
if em.audio_emotion == gt:
|
| 204 |
+
audio_correct += 1
|
| 205 |
+
if em.text_emotion == gt:
|
| 206 |
+
text_correct += 1
|
| 207 |
+
if em.fused_emotion == gt:
|
| 208 |
+
fused_correct += 1
|
| 209 |
+
logger.info(
|
| 210 |
+
f"{i:3d} | {gt:>12s} | {em.audio_emotion:>8s} {a_match} | "
|
| 211 |
+
f"{em.text_emotion:>8s} {t_match} | {em.fused_emotion:>8s} {f_match} | "
|
| 212 |
+
f"{em.fused_confidence:.3f}"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
n = len(ground_truths)
|
| 216 |
+
logger.info(f"\nAccuracy: audio={audio_correct}/{n} text={text_correct}/{n} fused={fused_correct}/{n}")
|
| 217 |
+
|
| 218 |
+
# SpeakerSummary ์ถ๋ ฅ
|
| 219 |
+
logger.info("\nSpeaker Summaries:")
|
| 220 |
+
for spk, summary in output.speaker_summaries.items():
|
| 221 |
+
logger.info(f" {spk}: dominant={summary.dominant_emotion}, "
|
| 222 |
+
f"conf={summary.avg_confidence:.3f}, "
|
| 223 |
+
f"dist={summary.emotion_distribution}")
|
| 224 |
+
|
| 225 |
+
if errors:
|
| 226 |
+
logger.error(f"\nFAILED โ {len(errors)} errors:")
|
| 227 |
+
for e in errors:
|
| 228 |
+
logger.error(f" - {e}")
|
| 229 |
+
return False
|
| 230 |
+
|
| 231 |
+
logger.info(f"\n{'='*70}")
|
| 232 |
+
logger.info("E2E ํ
์คํธ PASS โ ์์ด ํ์ดํ๋ผ์ธ ์ ์ ๋์")
|
| 233 |
+
logger.info(f"{'='*70}")
|
| 234 |
+
return True
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
if __name__ == "__main__":
|
| 238 |
+
success = run_e2e()
|
| 239 |
+
sys.exit(0 if success else 1)
|
scripts/test_meld_e2e_server.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""MELD English test sets โ E2E server test.
|
| 3 |
+
|
| 4 |
+
Uploads 8 MELD test WAVs to the deployed HF Spaces server,
|
| 5 |
+
runs the full pipeline (Stage 1โ2โ3), and compares results
|
| 6 |
+
against ground truth emotion labels.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python scripts/test_meld_e2e_server.py
|
| 10 |
+
python scripts/test_meld_e2e_server.py --server http://localhost:8000
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
import time
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
import requests
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 24 |
+
MELD_DIR = PROJECT_ROOT / "data" / "meld_test"
|
| 25 |
+
GT_PATH = MELD_DIR / "ground_truth.json"
|
| 26 |
+
|
| 27 |
+
DEFAULT_SERVER = "https://bbbakery-ustwo-api.hf.space"
|
| 28 |
+
POLL_INTERVAL = 5 # seconds
|
| 29 |
+
MAX_WAIT = 300 # 5 minutes per file
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def health_check(base: str) -> bool:
|
| 33 |
+
try:
|
| 34 |
+
r = requests.get(f"{base}/api/health", timeout=10)
|
| 35 |
+
data = r.json()
|
| 36 |
+
if data.get("status") == "ok":
|
| 37 |
+
print(f" โ
Server OK ({data.get('timestamp', '?')})")
|
| 38 |
+
return True
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f" โ Health check failed: {e}")
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def upload(base: str, wav_path: Path) -> str | None:
|
| 45 |
+
"""Upload WAV and return call_id."""
|
| 46 |
+
with open(wav_path, "rb") as f:
|
| 47 |
+
r = requests.post(
|
| 48 |
+
f"{base}/api/upload",
|
| 49 |
+
files={"file": (wav_path.name, f, "audio/wav")},
|
| 50 |
+
timeout=60,
|
| 51 |
+
)
|
| 52 |
+
if r.status_code != 200:
|
| 53 |
+
print(f" โ Upload failed ({r.status_code}): {r.text[:200]}")
|
| 54 |
+
return None
|
| 55 |
+
data = r.json()
|
| 56 |
+
return data.get("call_id")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def analyze_and_poll(base: str, call_id: str) -> dict | None:
|
| 60 |
+
"""Start analysis and poll until done."""
|
| 61 |
+
# Start
|
| 62 |
+
r = requests.post(f"{base}/api/analyze", params={"call_id": call_id}, timeout=30)
|
| 63 |
+
if r.status_code not in (200, 202):
|
| 64 |
+
print(f" โ Analyze start failed ({r.status_code}): {r.text[:200]}")
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
data = r.json()
|
| 68 |
+
if data.get("status") == "done":
|
| 69 |
+
return data.get("result")
|
| 70 |
+
|
| 71 |
+
# Poll
|
| 72 |
+
elapsed = 0
|
| 73 |
+
while elapsed < MAX_WAIT:
|
| 74 |
+
time.sleep(POLL_INTERVAL)
|
| 75 |
+
elapsed += POLL_INTERVAL
|
| 76 |
+
|
| 77 |
+
r = requests.get(f"{base}/api/analyze/{call_id}/status", timeout=15)
|
| 78 |
+
data = r.json()
|
| 79 |
+
status = data.get("status")
|
| 80 |
+
|
| 81 |
+
if status == "done":
|
| 82 |
+
return data.get("result")
|
| 83 |
+
elif status == "error":
|
| 84 |
+
print(f" โ Pipeline error: {data.get('error', '?')}")
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
mins, secs = divmod(elapsed, 60)
|
| 88 |
+
print(f" โณ {status}... ({int(mins)}m{int(secs)}s)", end="\r")
|
| 89 |
+
|
| 90 |
+
print(f" โ Timeout after {MAX_WAIT}s")
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def extract_emotions(result: dict) -> dict:
|
| 95 |
+
"""Extract emotion info from Stage 3 result."""
|
| 96 |
+
info = {}
|
| 97 |
+
|
| 98 |
+
# Character reactions โ emotions
|
| 99 |
+
reactions = result.get("character_reactions", [])
|
| 100 |
+
for i, rx in enumerate(reactions):
|
| 101 |
+
speaker = rx.get("speaker_id", f"speaker_{i}")
|
| 102 |
+
info[f"speaker_{i}_state"] = rx.get("solo_state", "?")
|
| 103 |
+
|
| 104 |
+
# Garden update
|
| 105 |
+
garden = result.get("garden_update", {})
|
| 106 |
+
info["garden_mood"] = garden.get("mood", "?")
|
| 107 |
+
info["garden_delta"] = garden.get("growth_delta", 0)
|
| 108 |
+
|
| 109 |
+
# Recap
|
| 110 |
+
recap = result.get("recap_card", {})
|
| 111 |
+
info["recap_headline"] = recap.get("headline", "?")
|
| 112 |
+
|
| 113 |
+
# Stage 2 emotions (if exposed in result)
|
| 114 |
+
stage2 = result.get("stage2_output", {})
|
| 115 |
+
if stage2:
|
| 116 |
+
for spk, summary in stage2.get("speaker_summaries", {}).items():
|
| 117 |
+
info[f"{spk}_dominant"] = summary.get("dominant_emotion", "?")
|
| 118 |
+
info[f"{spk}_distribution"] = summary.get("emotion_distribution", {})
|
| 119 |
+
|
| 120 |
+
return info
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def main():
|
| 124 |
+
parser = argparse.ArgumentParser(description="MELD E2E server test")
|
| 125 |
+
parser.add_argument("--server", default=DEFAULT_SERVER, help="Server base URL")
|
| 126 |
+
args = parser.parse_args()
|
| 127 |
+
base = args.server.rstrip("/")
|
| 128 |
+
|
| 129 |
+
print("=" * 70)
|
| 130 |
+
print(" MELD E2E Server Test")
|
| 131 |
+
print(f" Server: {base}")
|
| 132 |
+
print("=" * 70)
|
| 133 |
+
|
| 134 |
+
# Health check
|
| 135 |
+
print("\n[1] Health check")
|
| 136 |
+
if not health_check(base):
|
| 137 |
+
sys.exit(1)
|
| 138 |
+
|
| 139 |
+
# Load ground truth
|
| 140 |
+
print("\n[2] Loading ground truth")
|
| 141 |
+
with open(GT_PATH) as f:
|
| 142 |
+
gt = json.load(f)
|
| 143 |
+
print(f" {len(gt)} test sets loaded")
|
| 144 |
+
|
| 145 |
+
# Process each test set
|
| 146 |
+
print("\n[3] Running E2E tests\n")
|
| 147 |
+
results = {}
|
| 148 |
+
pass_count = 0
|
| 149 |
+
fail_count = 0
|
| 150 |
+
|
| 151 |
+
for tag in sorted(gt.keys()):
|
| 152 |
+
wav_path = MELD_DIR / f"{tag}.wav"
|
| 153 |
+
if not wav_path.exists():
|
| 154 |
+
print(f" โ ๏ธ {tag}: WAV not found, skipping")
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
gt_entry = gt[tag]
|
| 158 |
+
print(f" ๐ฆ {tag} โ {gt_entry['description']}")
|
| 159 |
+
print(f" Primary: {gt_entry['primary_emotion']} | Duration: {gt_entry['duration_sec']}s | Utts: {gt_entry['total_utterances']}")
|
| 160 |
+
|
| 161 |
+
# Upload
|
| 162 |
+
call_id = upload(base, wav_path)
|
| 163 |
+
if not call_id:
|
| 164 |
+
fail_count += 1
|
| 165 |
+
results[tag] = {"status": "upload_failed"}
|
| 166 |
+
continue
|
| 167 |
+
print(f" Upload OK โ {call_id}")
|
| 168 |
+
|
| 169 |
+
# Analyze + poll
|
| 170 |
+
print(f" Analyzing...", end="")
|
| 171 |
+
start_time = time.time()
|
| 172 |
+
result = analyze_and_poll(base, call_id)
|
| 173 |
+
elapsed = time.time() - start_time
|
| 174 |
+
|
| 175 |
+
if not result:
|
| 176 |
+
fail_count += 1
|
| 177 |
+
results[tag] = {"status": "analyze_failed", "call_id": call_id}
|
| 178 |
+
print()
|
| 179 |
+
continue
|
| 180 |
+
|
| 181 |
+
print(f"\r โ
Done in {elapsed:.1f}s")
|
| 182 |
+
|
| 183 |
+
# Extract emotions
|
| 184 |
+
emotions = extract_emotions(result)
|
| 185 |
+
|
| 186 |
+
# Check pipeline completeness
|
| 187 |
+
has_reactions = len(result.get("character_reactions", [])) > 0
|
| 188 |
+
has_garden = "garden_update" in result
|
| 189 |
+
has_recap = "recap_card" in result
|
| 190 |
+
|
| 191 |
+
status = "pass" if (has_reactions and has_garden and has_recap) else "partial"
|
| 192 |
+
if status == "pass":
|
| 193 |
+
pass_count += 1
|
| 194 |
+
else:
|
| 195 |
+
fail_count += 1
|
| 196 |
+
|
| 197 |
+
results[tag] = {
|
| 198 |
+
"status": status,
|
| 199 |
+
"call_id": call_id,
|
| 200 |
+
"elapsed_sec": round(elapsed, 1),
|
| 201 |
+
"has_reactions": has_reactions,
|
| 202 |
+
"has_garden": has_garden,
|
| 203 |
+
"has_recap": has_recap,
|
| 204 |
+
"emotions": emotions,
|
| 205 |
+
"full_result": result,
|
| 206 |
+
"ground_truth": {
|
| 207 |
+
"primary_emotion": gt_entry["primary_emotion"],
|
| 208 |
+
"emotion_distribution": gt_entry["emotion_distribution"],
|
| 209 |
+
},
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
# Print details
|
| 213 |
+
print(f" Reactions: {'โ
' if has_reactions else 'โ'} | Garden: {'โ
' if has_garden else 'โ'} | Recap: {'โ
' if has_recap else 'โ'}")
|
| 214 |
+
for k, v in emotions.items():
|
| 215 |
+
if not k.startswith("full_"):
|
| 216 |
+
print(f" {k}: {v}")
|
| 217 |
+
print()
|
| 218 |
+
|
| 219 |
+
# Save results
|
| 220 |
+
out_path = MELD_DIR / "e2e_results.json"
|
| 221 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 222 |
+
# Don't save full_result to keep file manageable
|
| 223 |
+
save_results = {}
|
| 224 |
+
for tag, r in results.items():
|
| 225 |
+
save_copy = {k: v for k, v in r.items() if k != "full_result"}
|
| 226 |
+
save_results[tag] = save_copy
|
| 227 |
+
json.dump(save_results, f, indent=2, ensure_ascii=False)
|
| 228 |
+
|
| 229 |
+
# Summary
|
| 230 |
+
print("=" * 70)
|
| 231 |
+
print(" SUMMARY")
|
| 232 |
+
print("=" * 70)
|
| 233 |
+
print(f"\n {'Tag':<25} {'Status':<10} {'Time':>6} {'Reactions':>10} {'Garden':>8} {'Recap':>7}")
|
| 234 |
+
print(" " + "-" * 70)
|
| 235 |
+
for tag in sorted(results.keys()):
|
| 236 |
+
r = results[tag]
|
| 237 |
+
status_icon = "โ
" if r["status"] == "pass" else "โ"
|
| 238 |
+
elapsed = f"{r.get('elapsed_sec', 0):.1f}s" if "elapsed_sec" in r else "โ"
|
| 239 |
+
react = "โ
" if r.get("has_reactions") else "โ"
|
| 240 |
+
garden = "โ
" if r.get("has_garden") else "โ"
|
| 241 |
+
recap = "โ
" if r.get("has_recap") else "โ"
|
| 242 |
+
print(f" {tag:<25} {status_icon:<10} {elapsed:>6} {react:>10} {garden:>8} {recap:>7}")
|
| 243 |
+
|
| 244 |
+
print(f"\n Total: {pass_count} pass / {fail_count} fail / {len(results)} total")
|
| 245 |
+
print(f" Results saved: {out_path}")
|
| 246 |
+
print("=" * 70)
|
| 247 |
+
|
| 248 |
+
return fail_count == 0
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
success = main()
|
| 253 |
+
sys.exit(0 if success else 1)
|
scripts/train_emotion2vec.py
ADDED
|
File without changes
|
scripts/train_fusion_weights.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train per-emotion fusion weights via gradient descent.
|
| 3 |
+
|
| 4 |
+
Inputs:
|
| 5 |
+
- --manifest JSON: list of {"path","text","label","source",...} (2,821 samples)
|
| 6 |
+
- --preds-cache JSON: {"audio_preds": [dict(7)], "text_preds": [dict(7)]}
|
| 7 |
+
|
| 8 |
+
Output dir receives:
|
| 9 |
+
- trained_weights.json โ learned w_a / w_t / val_macro_f1
|
| 10 |
+
- trained_fusion_report.md โ comparison: audio-only, fixed 60/40, greedy optimal, trained
|
| 11 |
+
- trained_fusion_curve.png โ train/val loss + F1 curves
|
| 12 |
+
|
| 13 |
+
Parameterization:
|
| 14 |
+
w_a[L] = sigmoid(ฮฑ[L]), w_t[L] = 1 - w_a[L] # 7 params total
|
| 15 |
+
fused[L] = p_a[L]*w_a[L] + p_t[L]*w_t[L]
|
| 16 |
+
fused โ normalize over L
|
| 17 |
+
loss = NLL(log fused, y) + ฮป * ||ฮฑ||ยฒ
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
from collections import Counter
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
from sklearn.metrics import f1_score
|
| 31 |
+
from sklearn.model_selection import train_test_split
|
| 32 |
+
|
| 33 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class FusionHead(nn.Module):
|
| 40 |
+
def __init__(self, init_audio_frac: float = 0.6):
|
| 41 |
+
super().__init__()
|
| 42 |
+
init_val = float(torch.logit(torch.tensor(init_audio_frac)))
|
| 43 |
+
self.alpha = nn.Parameter(torch.full((7,), init_val))
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def w_a(self) -> torch.Tensor:
|
| 47 |
+
return torch.sigmoid(self.alpha)
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def w_t(self) -> torch.Tensor:
|
| 51 |
+
return 1.0 - self.w_a
|
| 52 |
+
|
| 53 |
+
def forward(self, p_a: torch.Tensor, p_t: torch.Tensor) -> torch.Tensor:
|
| 54 |
+
fused = p_a * self.w_a + p_t * self.w_t
|
| 55 |
+
return fused / fused.sum(dim=1, keepdim=True).clamp(min=1e-8)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def probs_to_tensor(preds: list[dict]) -> torch.Tensor:
|
| 59 |
+
arr = np.array([[p.get(l, 0.0) for l in PROJECT_LABELS] for p in preds], dtype=np.float32)
|
| 60 |
+
return torch.from_numpy(arr)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def map_label(lbl: str) -> str:
|
| 64 |
+
return "joy" if lbl == "happiness" else lbl
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def eval_weights(p_a: torch.Tensor, p_t: torch.Tensor, y: np.ndarray,
|
| 68 |
+
w_a_vec: np.ndarray) -> dict:
|
| 69 |
+
w_a = torch.from_numpy(w_a_vec.astype(np.float32))
|
| 70 |
+
w_t = 1.0 - w_a
|
| 71 |
+
fused = p_a * w_a + p_t * w_t
|
| 72 |
+
fused = fused / fused.sum(dim=1, keepdim=True).clamp(min=1e-8)
|
| 73 |
+
pred = fused.argmax(dim=1).numpy()
|
| 74 |
+
macro = f1_score(y, pred, average="macro")
|
| 75 |
+
per_class = {
|
| 76 |
+
PROJECT_LABELS[i]: f1_score((y == i).astype(int), (pred == i).astype(int))
|
| 77 |
+
for i in range(7)
|
| 78 |
+
}
|
| 79 |
+
return {"macro_f1": float(macro), "per_class": {k: float(v) for k, v in per_class.items()}}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def train(p_a_tr, p_t_tr, y_tr, p_a_vl, p_t_vl, y_vl,
|
| 83 |
+
lr=0.05, epochs=500, l2=0.01, patience=50):
|
| 84 |
+
model = FusionHead()
|
| 85 |
+
opt = torch.optim.Adam(model.parameters(), lr=lr)
|
| 86 |
+
nll = nn.NLLLoss()
|
| 87 |
+
|
| 88 |
+
history = {"train_loss": [], "val_f1": []}
|
| 89 |
+
best_f1, best_alpha, waited = -1.0, None, 0
|
| 90 |
+
|
| 91 |
+
y_tr_t = torch.from_numpy(y_tr).long()
|
| 92 |
+
y_vl_t = torch.from_numpy(y_vl).long()
|
| 93 |
+
|
| 94 |
+
for epoch in range(epochs):
|
| 95 |
+
model.train()
|
| 96 |
+
opt.zero_grad()
|
| 97 |
+
fused = model(p_a_tr, p_t_tr)
|
| 98 |
+
loss = nll(torch.log(fused.clamp(min=1e-8)), y_tr_t) + l2 * (model.alpha ** 2).sum()
|
| 99 |
+
loss.backward()
|
| 100 |
+
opt.step()
|
| 101 |
+
|
| 102 |
+
model.eval()
|
| 103 |
+
with torch.no_grad():
|
| 104 |
+
val_fused = model(p_a_vl, p_t_vl)
|
| 105 |
+
val_pred = val_fused.argmax(dim=1).numpy()
|
| 106 |
+
val_f1 = f1_score(y_vl, val_pred, average="macro")
|
| 107 |
+
|
| 108 |
+
history["train_loss"].append(float(loss.item()))
|
| 109 |
+
history["val_f1"].append(float(val_f1))
|
| 110 |
+
|
| 111 |
+
if val_f1 > best_f1:
|
| 112 |
+
best_f1, best_alpha, waited = float(val_f1), model.alpha.detach().clone(), 0
|
| 113 |
+
else:
|
| 114 |
+
waited += 1
|
| 115 |
+
if waited >= patience:
|
| 116 |
+
logger.info("Early stop at epoch %d (patience=%d)", epoch, patience)
|
| 117 |
+
break
|
| 118 |
+
|
| 119 |
+
final_alpha = model.alpha.detach().clone()
|
| 120 |
+
model.alpha.data = best_alpha
|
| 121 |
+
return model, best_f1, history, final_alpha
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def plot_curve(history, output_path: Path) -> None:
|
| 125 |
+
import matplotlib
|
| 126 |
+
matplotlib.use("Agg")
|
| 127 |
+
import matplotlib.pyplot as plt
|
| 128 |
+
|
| 129 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
|
| 130 |
+
ax1.plot(history["train_loss"], color="#F44336", label="train CE + L2")
|
| 131 |
+
ax1.set_xlabel("Epoch"); ax1.set_ylabel("Loss"); ax1.set_title("Training loss")
|
| 132 |
+
ax1.grid(alpha=0.3); ax1.legend()
|
| 133 |
+
|
| 134 |
+
ax2.plot(history["val_f1"], color="#4CAF50", label="val macro F1")
|
| 135 |
+
ax2.set_xlabel("Epoch"); ax2.set_ylabel("Macro F1"); ax2.set_title("Validation macro F1")
|
| 136 |
+
ax2.grid(alpha=0.3); ax2.legend()
|
| 137 |
+
|
| 138 |
+
plt.tight_layout()
|
| 139 |
+
plt.savefig(str(output_path), dpi=150)
|
| 140 |
+
plt.close()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def write_report(output_path: Path, num_train: int, num_val: int, labels_dist: dict,
|
| 144 |
+
audio_only: dict, fixed: dict, greedy: dict, trained: dict,
|
| 145 |
+
trained_weights: dict, greedy_weights: dict) -> None:
|
| 146 |
+
lines = ["# Fusion Weight Training Report (v2)\n"]
|
| 147 |
+
lines.append(f"## Dataset\n\nTotal samples: **{num_train + num_val}** (train {num_train}, val {num_val})\n")
|
| 148 |
+
lines.append("### Label distribution\n\n| Label | Count |\n|---|---|")
|
| 149 |
+
for lbl, c in sorted(labels_dist.items(), key=lambda x: -x[1]):
|
| 150 |
+
lines.append(f"| {lbl} | {c} |")
|
| 151 |
+
lines.append("")
|
| 152 |
+
lines.append("## Macro F1 Comparison (validation set)\n")
|
| 153 |
+
lines.append("| Strategy | Macro F1 |")
|
| 154 |
+
lines.append("|---|---|")
|
| 155 |
+
lines.append(f"| Audio-only (argmax p_audio) | {audio_only['macro_f1']:.4f} |")
|
| 156 |
+
lines.append(f"| Fixed 60/40 | {fixed['macro_f1']:.4f} |")
|
| 157 |
+
lines.append(f"| Greedy grid (v1 weights) | {greedy['macro_f1']:.4f} |")
|
| 158 |
+
lines.append(f"| **Trained (gradient descent)** | **{trained['macro_f1']:.4f}** |")
|
| 159 |
+
lines.append("")
|
| 160 |
+
lines.append("## Per-class F1 (validation set)\n")
|
| 161 |
+
lines.append("| Emotion | Audio-only | Fixed 60/40 | Greedy | Trained |")
|
| 162 |
+
lines.append("|---|---|---|---|---|")
|
| 163 |
+
for lbl in PROJECT_LABELS:
|
| 164 |
+
lines.append(f"| {lbl} | {audio_only['per_class'][lbl]:.4f} | "
|
| 165 |
+
f"{fixed['per_class'][lbl]:.4f} | {greedy['per_class'][lbl]:.4f} | "
|
| 166 |
+
f"{trained['per_class'][lbl]:.4f} |")
|
| 167 |
+
lines.append("")
|
| 168 |
+
lines.append("## Learned weights\n")
|
| 169 |
+
lines.append("| Emotion | Audio (trained) | Text (trained) | Audio (greedy v1) |")
|
| 170 |
+
lines.append("|---|---|---|---|")
|
| 171 |
+
for lbl in PROJECT_LABELS:
|
| 172 |
+
w = trained_weights[lbl]
|
| 173 |
+
gw = greedy_weights.get(lbl, {"audio": None})
|
| 174 |
+
g_a = f"{gw['audio']:.2f}" if gw.get("audio") is not None else "โ"
|
| 175 |
+
lines.append(f"| {lbl} | {w['audio']:.2f} | {w['text']:.2f} | {g_a} |")
|
| 176 |
+
lines.append("")
|
| 177 |
+
output_path.write_text("\n".join(lines))
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def main() -> None:
|
| 181 |
+
parser = argparse.ArgumentParser()
|
| 182 |
+
parser.add_argument("--manifest", type=Path, required=True)
|
| 183 |
+
parser.add_argument("--preds-cache", type=Path, required=True)
|
| 184 |
+
parser.add_argument("--output-dir", type=Path, required=True)
|
| 185 |
+
parser.add_argument("--lr", type=float, default=0.05)
|
| 186 |
+
parser.add_argument("--epochs", type=int, default=500)
|
| 187 |
+
parser.add_argument("--l2", type=float, default=0.01)
|
| 188 |
+
parser.add_argument("--patience", type=int, default=50)
|
| 189 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 190 |
+
parser.add_argument("--use-last-alpha", action="store_true",
|
| 191 |
+
help="Use final-epoch alpha instead of best-val-F1 alpha (keeps differentiation)")
|
| 192 |
+
args = parser.parse_args()
|
| 193 |
+
|
| 194 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
|
| 196 |
+
manifest = json.loads(args.manifest.read_text())
|
| 197 |
+
cache = json.loads(args.preds_cache.read_text())
|
| 198 |
+
audio_preds = cache["audio_preds"]
|
| 199 |
+
text_preds = cache["text_preds"]
|
| 200 |
+
|
| 201 |
+
if not (len(manifest) == len(audio_preds) == len(text_preds)):
|
| 202 |
+
raise ValueError(f"Size mismatch: manifest={len(manifest)}, audio={len(audio_preds)}, text={len(text_preds)}")
|
| 203 |
+
|
| 204 |
+
labels = [map_label(r["label"]) for r in manifest]
|
| 205 |
+
labels_dist = dict(Counter(labels))
|
| 206 |
+
logger.info("Loaded %d samples. Distribution: %s", len(manifest), labels_dist)
|
| 207 |
+
|
| 208 |
+
p_a = probs_to_tensor(audio_preds)
|
| 209 |
+
p_t = probs_to_tensor(text_preds)
|
| 210 |
+
y = np.array([PROJECT_LABELS.index(l) for l in labels])
|
| 211 |
+
|
| 212 |
+
# Stratified 80/20
|
| 213 |
+
tr_idx, vl_idx = train_test_split(
|
| 214 |
+
np.arange(len(y)), test_size=0.2, stratify=y, random_state=args.seed,
|
| 215 |
+
)
|
| 216 |
+
logger.info("Train/Val: %d / %d", len(tr_idx), len(vl_idx))
|
| 217 |
+
|
| 218 |
+
torch.manual_seed(args.seed)
|
| 219 |
+
model, best_f1, history, final_alpha = train(
|
| 220 |
+
p_a[tr_idx], p_t[tr_idx], y[tr_idx],
|
| 221 |
+
p_a[vl_idx], p_t[vl_idx], y[vl_idx],
|
| 222 |
+
lr=args.lr, epochs=args.epochs, l2=args.l2, patience=args.patience,
|
| 223 |
+
)
|
| 224 |
+
logger.info("Best val macro F1: %.4f", best_f1)
|
| 225 |
+
|
| 226 |
+
# Select which alpha to deploy: "best" (peak val F1) or "last" (final epoch)
|
| 227 |
+
if args.use_last_alpha:
|
| 228 |
+
model.alpha.data = final_alpha
|
| 229 |
+
logger.info("Using LAST-epoch alpha (--use-last-alpha)")
|
| 230 |
+
# Derive trained weights dict
|
| 231 |
+
w_a_np = model.w_a.detach().numpy()
|
| 232 |
+
trained_weights = {
|
| 233 |
+
PROJECT_LABELS[i]: {"audio": round(float(w_a_np[i]), 2), "text": round(float(1 - w_a_np[i]), 2)}
|
| 234 |
+
for i in range(7)
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Also compute last-alpha weights for comparison
|
| 238 |
+
last_w_a = torch.sigmoid(final_alpha).numpy()
|
| 239 |
+
last_weights = {
|
| 240 |
+
PROJECT_LABELS[i]: {"audio": round(float(last_w_a[i]), 2), "text": round(float(1 - last_w_a[i]), 2)}
|
| 241 |
+
for i in range(7)
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
# Baselines on val split
|
| 245 |
+
pa_vl = p_a[vl_idx]; pt_vl = p_t[vl_idx]; y_vl = y[vl_idx]
|
| 246 |
+
|
| 247 |
+
# Audio-only argmax
|
| 248 |
+
audio_only_pred = pa_vl.argmax(dim=1).numpy()
|
| 249 |
+
audio_only = {
|
| 250 |
+
"macro_f1": float(f1_score(y_vl, audio_only_pred, average="macro")),
|
| 251 |
+
"per_class": {
|
| 252 |
+
PROJECT_LABELS[i]: float(f1_score((y_vl == i).astype(int), (audio_only_pred == i).astype(int)))
|
| 253 |
+
for i in range(7)
|
| 254 |
+
},
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
fixed = eval_weights(pa_vl, pt_vl, y_vl, np.full(7, 0.6))
|
| 258 |
+
# Greedy v1 weights โ hardcode from previous report
|
| 259 |
+
GREEDY_V1 = {
|
| 260 |
+
"neutral": 0.75, "joy": 0.55, "sadness": 0.40, "anger": 0.65,
|
| 261 |
+
"surprise": 0.45, "fear": 0.00, "disgust": 0.80,
|
| 262 |
+
}
|
| 263 |
+
greedy_w_a = np.array([GREEDY_V1[l] for l in PROJECT_LABELS])
|
| 264 |
+
greedy = eval_weights(pa_vl, pt_vl, y_vl, greedy_w_a)
|
| 265 |
+
greedy_weights = {l: {"audio": GREEDY_V1[l]} for l in PROJECT_LABELS}
|
| 266 |
+
|
| 267 |
+
trained = eval_weights(pa_vl, pt_vl, y_vl, w_a_np)
|
| 268 |
+
|
| 269 |
+
logger.info("Audio-only val macro F1: %.4f", audio_only["macro_f1"])
|
| 270 |
+
logger.info("Fixed 60/40 val macro F1: %.4f", fixed["macro_f1"])
|
| 271 |
+
logger.info("Greedy v1 val macro F1: %.4f", greedy["macro_f1"])
|
| 272 |
+
logger.info("Trained val macro F1: %.4f", trained["macro_f1"])
|
| 273 |
+
|
| 274 |
+
# Save
|
| 275 |
+
(args.output_dir / "trained_weights.json").write_text(json.dumps({
|
| 276 |
+
"val_macro_f1": trained["macro_f1"],
|
| 277 |
+
"weights": trained_weights,
|
| 278 |
+
"last_epoch_weights": last_weights,
|
| 279 |
+
"train_size": int(len(tr_idx)),
|
| 280 |
+
"val_size": int(len(vl_idx)),
|
| 281 |
+
"hyperparams": {"lr": args.lr, "l2": args.l2, "epochs": args.epochs,
|
| 282 |
+
"patience": args.patience, "seed": args.seed,
|
| 283 |
+
"use_last_alpha": args.use_last_alpha},
|
| 284 |
+
}, indent=2, ensure_ascii=False))
|
| 285 |
+
|
| 286 |
+
plot_curve(history, args.output_dir / "trained_fusion_curve.png")
|
| 287 |
+
write_report(
|
| 288 |
+
args.output_dir / "trained_fusion_report.md",
|
| 289 |
+
len(tr_idx), len(vl_idx), labels_dist,
|
| 290 |
+
audio_only, fixed, greedy, trained,
|
| 291 |
+
trained_weights, greedy_weights,
|
| 292 |
+
)
|
| 293 |
+
logger.info("Done. Results saved to %s", args.output_dir)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
main()
|
scripts/train_kcelectra.py
ADDED
|
File without changes
|
scripts/train_lora_emotion2vec.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Manual LoRA fine-tuning for emotion2vec_plus_base โ 7-class emotion.
|
| 3 |
+
|
| 4 |
+
Wraps frozen attention layers with low-rank adapters (LoRALinear),
|
| 5 |
+
replaces the 9-class proj head with a 7-class MLPHead, and trains
|
| 6 |
+
with FocalLoss + disgust F1 gating.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
# Quick smoke test (CPU)
|
| 10 |
+
python scripts/train_lora_emotion2vec.py \
|
| 11 |
+
--train-manifest data/lora_7class/train_manifest.json \
|
| 12 |
+
--val-manifest data/lora_7class/val_manifest.json \
|
| 13 |
+
--output-dir data/models/lora_emotion2vec_7class \
|
| 14 |
+
--epochs 3 --device cpu
|
| 15 |
+
|
| 16 |
+
# Full training (GPU, RTX 5050 8GB)
|
| 17 |
+
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
|
| 18 |
+
python scripts/train_lora_emotion2vec.py \
|
| 19 |
+
--train-manifest data/lora_7class/train_manifest.json \
|
| 20 |
+
--val-manifest data/lora_7class/val_manifest.json \
|
| 21 |
+
--output-dir data/models/lora_emotion2vec_7class \
|
| 22 |
+
--epochs 20 --batch-size 4 --accumulate-steps 8 --device cuda
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import logging
|
| 30 |
+
import random
|
| 31 |
+
import sys
|
| 32 |
+
import time
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch
|
| 37 |
+
import torch.nn as nn
|
| 38 |
+
import torch.nn.functional as F
|
| 39 |
+
from torch.utils.data import DataLoader, Dataset
|
| 40 |
+
|
| 41 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
# 7-class label taxonomy (matches prepare_lora_dataset.py)
|
| 45 |
+
LABELS_7CLASS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 46 |
+
LABEL2IDX = {label: i for i, label in enumerate(LABELS_7CLASS)}
|
| 47 |
+
NUM_CLASSES = len(LABELS_7CLASS)
|
| 48 |
+
DISGUST_IDX = LABEL2IDX["disgust"] # 2
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 52 |
+
# LoRA Components
|
| 53 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 54 |
+
|
| 55 |
+
class LoRALinear(nn.Module):
|
| 56 |
+
"""Low-rank adapter wrapping a frozen nn.Linear.
|
| 57 |
+
|
| 58 |
+
At init, B is zero so LoRA contribution is zero (original behavior preserved).
|
| 59 |
+
scaling = alpha / r controls the magnitude of the LoRA update.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(self, original: nn.Linear, r: int = 16, alpha: int = 32, dropout: float = 0.1):
|
| 63 |
+
super().__init__()
|
| 64 |
+
self.original = original
|
| 65 |
+
self.r = r
|
| 66 |
+
self.scaling = alpha / r
|
| 67 |
+
|
| 68 |
+
# Freeze original weights
|
| 69 |
+
self.original.weight.requires_grad = False
|
| 70 |
+
if self.original.bias is not None:
|
| 71 |
+
self.original.bias.requires_grad = False
|
| 72 |
+
|
| 73 |
+
in_features = original.in_features
|
| 74 |
+
out_features = original.out_features
|
| 75 |
+
|
| 76 |
+
# Low-rank matrices
|
| 77 |
+
self.lora_A = nn.Linear(in_features, r, bias=False)
|
| 78 |
+
self.lora_B = nn.Linear(r, out_features, bias=False)
|
| 79 |
+
self.dropout = nn.Dropout(dropout)
|
| 80 |
+
|
| 81 |
+
# Init: A = kaiming, B = zero (so initial LoRA output = 0)
|
| 82 |
+
nn.init.kaiming_uniform_(self.lora_A.weight)
|
| 83 |
+
nn.init.zeros_(self.lora_B.weight)
|
| 84 |
+
|
| 85 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 86 |
+
base_out = self.original(x)
|
| 87 |
+
lora_out = self.lora_B(self.dropout(self.lora_A(x))) * self.scaling
|
| 88 |
+
return base_out + lora_out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def merge_lora_linear(lora: LoRALinear) -> nn.Linear:
|
| 92 |
+
"""Merge LoRA weights into a plain nn.Linear for inference.
|
| 93 |
+
|
| 94 |
+
W_merged = W_original + scaling * B.weight @ A.weight
|
| 95 |
+
"""
|
| 96 |
+
with torch.no_grad():
|
| 97 |
+
merged_weight = (
|
| 98 |
+
lora.original.weight
|
| 99 |
+
+ lora.scaling * lora.lora_B.weight @ lora.lora_A.weight
|
| 100 |
+
)
|
| 101 |
+
bias = lora.original.bias
|
| 102 |
+
|
| 103 |
+
merged = nn.Linear(
|
| 104 |
+
lora.original.in_features,
|
| 105 |
+
lora.original.out_features,
|
| 106 |
+
bias=bias is not None,
|
| 107 |
+
)
|
| 108 |
+
merged.weight = nn.Parameter(merged_weight)
|
| 109 |
+
if bias is not None:
|
| 110 |
+
merged.bias = nn.Parameter(bias.clone())
|
| 111 |
+
return merged
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def inject_lora(
|
| 115 |
+
encoder: nn.Module,
|
| 116 |
+
r: int = 16,
|
| 117 |
+
alpha: int = 32,
|
| 118 |
+
dropout: float = 0.1,
|
| 119 |
+
) -> None:
|
| 120 |
+
"""Replace attn.qkv and attn.proj in each block with LoRALinear.
|
| 121 |
+
|
| 122 |
+
emotion2vec uses FUSED QKV: attn.qkv: Linear(768, 2304)
|
| 123 |
+
and attn.proj: Linear(768, 768). 8 blocks total.
|
| 124 |
+
MLP layers are NOT wrapped.
|
| 125 |
+
"""
|
| 126 |
+
for block in encoder.blocks:
|
| 127 |
+
block.attn.qkv = LoRALinear(block.attn.qkv, r=r, alpha=alpha, dropout=dropout)
|
| 128 |
+
block.attn.proj = LoRALinear(block.attn.proj, r=r, alpha=alpha, dropout=dropout)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 132 |
+
# Model Components
|
| 133 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 134 |
+
|
| 135 |
+
class MLPHead(nn.Module):
|
| 136 |
+
"""Multi-layer classification head: 768 โ 512 โ 256 โ num_classes."""
|
| 137 |
+
|
| 138 |
+
def __init__(self, in_dim: int = 768, num_classes: int = NUM_CLASSES, dropout: float = 0.3):
|
| 139 |
+
super().__init__()
|
| 140 |
+
self.net = nn.Sequential(
|
| 141 |
+
nn.Linear(in_dim, 512),
|
| 142 |
+
nn.BatchNorm1d(512),
|
| 143 |
+
nn.GELU(),
|
| 144 |
+
nn.Dropout(dropout),
|
| 145 |
+
nn.Linear(512, 256),
|
| 146 |
+
nn.BatchNorm1d(256),
|
| 147 |
+
nn.GELU(),
|
| 148 |
+
nn.Dropout(dropout),
|
| 149 |
+
nn.Linear(256, num_classes),
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 153 |
+
return self.net(x)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class FocalLoss(nn.Module):
|
| 157 |
+
"""Focal Loss with optional label smoothing and class weights."""
|
| 158 |
+
|
| 159 |
+
def __init__(self, weight=None, gamma: float = 2.0, label_smoothing: float = 0.05):
|
| 160 |
+
super().__init__()
|
| 161 |
+
self.gamma = gamma
|
| 162 |
+
self.weight = weight
|
| 163 |
+
self.label_smoothing = label_smoothing
|
| 164 |
+
|
| 165 |
+
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
|
| 166 |
+
ce_loss = F.cross_entropy(
|
| 167 |
+
logits, targets, weight=self.weight,
|
| 168 |
+
label_smoothing=self.label_smoothing, reduction="none",
|
| 169 |
+
)
|
| 170 |
+
pt = torch.exp(-ce_loss)
|
| 171 |
+
focal_loss = ((1 - pt) ** self.gamma) * ce_loss
|
| 172 |
+
return focal_loss.mean()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 176 |
+
# Dataset
|
| 177 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 178 |
+
|
| 179 |
+
class EmotionDataset(Dataset):
|
| 180 |
+
"""Load audio from unified manifest JSON for 7-class LoRA training.
|
| 181 |
+
|
| 182 |
+
Manifest format: list of {"audio_path": str, "label": str, ...}
|
| 183 |
+
OR {"path": str, "label": str, ...} (for backward compat).
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
def __init__(
|
| 187 |
+
self,
|
| 188 |
+
manifest_path: str,
|
| 189 |
+
max_duration_sec: float = 8.0,
|
| 190 |
+
phone_augment_prob: float = 0.0,
|
| 191 |
+
noise_augment_prob: float = 0.0,
|
| 192 |
+
):
|
| 193 |
+
import torchaudio # lazy import
|
| 194 |
+
|
| 195 |
+
with open(manifest_path, encoding="utf-8") as f:
|
| 196 |
+
self.samples = json.load(f)
|
| 197 |
+
|
| 198 |
+
self.max_samples = int(max_duration_sec * 16000)
|
| 199 |
+
self.phone_augment_prob = phone_augment_prob
|
| 200 |
+
self.noise_augment_prob = noise_augment_prob
|
| 201 |
+
self._phone_sim = None
|
| 202 |
+
|
| 203 |
+
def _get_phone_sim(self):
|
| 204 |
+
if self._phone_sim is None:
|
| 205 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
| 206 |
+
from common.phone_simulator import PhoneSimulator, CompandingType
|
| 207 |
+
self._phone_sim = PhoneSimulator(companding=CompandingType.ALAW)
|
| 208 |
+
return self._phone_sim
|
| 209 |
+
|
| 210 |
+
def __len__(self):
|
| 211 |
+
return len(self.samples)
|
| 212 |
+
|
| 213 |
+
def __getitem__(self, idx):
|
| 214 |
+
import torchaudio
|
| 215 |
+
|
| 216 |
+
sample = self.samples[idx]
|
| 217 |
+
# Support both "audio_path" and "path" keys
|
| 218 |
+
audio_path = sample.get("audio_path") or sample.get("path", "")
|
| 219 |
+
|
| 220 |
+
waveform, sr = torchaudio.load(audio_path)
|
| 221 |
+
# Mono
|
| 222 |
+
if waveform.shape[0] > 1:
|
| 223 |
+
waveform = waveform.mean(dim=0, keepdim=True)
|
| 224 |
+
waveform = waveform.squeeze(0) # (T,)
|
| 225 |
+
|
| 226 |
+
# Resample to 16kHz
|
| 227 |
+
if sr != 16000:
|
| 228 |
+
waveform = torchaudio.functional.resample(waveform, sr, 16000)
|
| 229 |
+
|
| 230 |
+
# Truncate
|
| 231 |
+
if waveform.shape[0] > self.max_samples:
|
| 232 |
+
waveform = waveform[:self.max_samples]
|
| 233 |
+
|
| 234 |
+
audio = waveform.numpy()
|
| 235 |
+
|
| 236 |
+
# Augmentation
|
| 237 |
+
r = random.random()
|
| 238 |
+
if r < self.phone_augment_prob:
|
| 239 |
+
sim = self._get_phone_sim()
|
| 240 |
+
audio, _ = sim.process(audio, 16000)
|
| 241 |
+
import librosa
|
| 242 |
+
audio = librosa.resample(audio, orig_sr=8000, target_sr=16000)
|
| 243 |
+
elif r < self.phone_augment_prob + self.noise_augment_prob:
|
| 244 |
+
snr_db = random.uniform(10, 20)
|
| 245 |
+
signal_power = np.mean(audio ** 2)
|
| 246 |
+
noise_power = signal_power / (10 ** (snr_db / 10))
|
| 247 |
+
noise = np.random.normal(0, np.sqrt(max(noise_power, 1e-10)), len(audio)).astype(np.float32)
|
| 248 |
+
audio = audio + noise
|
| 249 |
+
|
| 250 |
+
label = LABEL2IDX[sample["label"]]
|
| 251 |
+
return torch.tensor(audio, dtype=torch.float32), label
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def collate_fn(batch):
|
| 255 |
+
"""Pad waveforms to same length in batch."""
|
| 256 |
+
waveforms, labels = zip(*batch)
|
| 257 |
+
max_len = max(w.shape[0] for w in waveforms)
|
| 258 |
+
padded = torch.zeros(len(waveforms), max_len)
|
| 259 |
+
for i, w in enumerate(waveforms):
|
| 260 |
+
padded[i, :w.shape[0]] = w
|
| 261 |
+
return padded, torch.tensor(labels, dtype=torch.long)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 265 |
+
# Model Loading & Forward
|
| 266 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 267 |
+
|
| 268 |
+
def load_model(device: str, r: int = 16, alpha: int = 32, dropout: float = 0.1):
|
| 269 |
+
"""Load emotion2vec_plus_base, freeze all, inject LoRA, replace proj."""
|
| 270 |
+
from funasr import AutoModel
|
| 271 |
+
|
| 272 |
+
logger.info("Loading emotion2vec_plus_base...")
|
| 273 |
+
fmodel = AutoModel(model="iic/emotion2vec_plus_base", device=device, hub="hf")
|
| 274 |
+
encoder = fmodel.model
|
| 275 |
+
|
| 276 |
+
# Freeze everything
|
| 277 |
+
for param in encoder.parameters():
|
| 278 |
+
param.requires_grad = False
|
| 279 |
+
|
| 280 |
+
# Inject LoRA adapters into attention layers
|
| 281 |
+
inject_lora(encoder, r=r, alpha=alpha, dropout=dropout)
|
| 282 |
+
|
| 283 |
+
# Replace 9-class proj with 7-class MLPHead
|
| 284 |
+
old_proj = encoder.proj
|
| 285 |
+
encoder.proj = MLPHead(768, NUM_CLASSES)
|
| 286 |
+
logger.info("Replaced proj: Linear(768, %d) -> MLPHead(768->512->256->%d)",
|
| 287 |
+
old_proj.out_features, NUM_CLASSES)
|
| 288 |
+
|
| 289 |
+
# Move entire model (including new LoRA params + MLPHead) to device
|
| 290 |
+
encoder = encoder.to(device)
|
| 291 |
+
return encoder
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def forward_pass(encoder, waveforms: torch.Tensor, device: str) -> torch.Tensor:
|
| 295 |
+
"""Differentiable forward pass through emotion2vec with LoRA.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
encoder: emotion2vec model with LoRA injected
|
| 299 |
+
waveforms: (B, T) float32, 16kHz
|
| 300 |
+
device: compute device
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
logits: (B, 7)
|
| 304 |
+
"""
|
| 305 |
+
waveforms = waveforms.to(device)
|
| 306 |
+
|
| 307 |
+
# Layer norm (per emotion2vec inference)
|
| 308 |
+
if encoder.cfg.normalize:
|
| 309 |
+
normed = []
|
| 310 |
+
for i in range(waveforms.shape[0]):
|
| 311 |
+
normed.append(F.layer_norm(waveforms[i], waveforms[i].shape))
|
| 312 |
+
waveforms = torch.stack(normed)
|
| 313 |
+
|
| 314 |
+
# Extract features
|
| 315 |
+
feats = encoder.extract_features(waveforms, padding_mask=None)
|
| 316 |
+
x = feats["x"] # (B, T', 768)
|
| 317 |
+
|
| 318 |
+
# Mean pool + classify
|
| 319 |
+
pooled = x.mean(dim=1) # (B, 768)
|
| 320 |
+
logits = encoder.proj(pooled) # (B, 7)
|
| 321 |
+
return logits
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 325 |
+
# Validation
|
| 326 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 327 |
+
|
| 328 |
+
@torch.no_grad()
|
| 329 |
+
def validate(encoder, val_loader, device, criterion):
|
| 330 |
+
"""Run validation, return metrics including disgust_f1."""
|
| 331 |
+
encoder.eval()
|
| 332 |
+
total_loss = 0
|
| 333 |
+
y_true, y_pred = [], []
|
| 334 |
+
|
| 335 |
+
for waveforms, labels in val_loader:
|
| 336 |
+
labels = labels.to(device)
|
| 337 |
+
logits = forward_pass(encoder, waveforms, device)
|
| 338 |
+
loss = criterion(logits, labels)
|
| 339 |
+
total_loss += loss.item() * labels.size(0)
|
| 340 |
+
|
| 341 |
+
preds = logits.argmax(dim=-1)
|
| 342 |
+
y_true.extend(labels.cpu().tolist())
|
| 343 |
+
y_pred.extend(preds.cpu().tolist())
|
| 344 |
+
|
| 345 |
+
from sklearn.metrics import precision_recall_fscore_support, accuracy_score, confusion_matrix
|
| 346 |
+
|
| 347 |
+
accuracy = accuracy_score(y_true, y_pred)
|
| 348 |
+
_, _, f1_per_class, _ = precision_recall_fscore_support(
|
| 349 |
+
y_true, y_pred, labels=list(range(NUM_CLASSES)), average=None, zero_division=0,
|
| 350 |
+
)
|
| 351 |
+
macro_f1 = float(np.mean(f1_per_class))
|
| 352 |
+
disgust_f1 = float(f1_per_class[DISGUST_IDX])
|
| 353 |
+
|
| 354 |
+
per_class = {LABELS_7CLASS[i]: round(float(f1_per_class[i]), 4) for i in range(NUM_CLASSES)}
|
| 355 |
+
avg_loss = total_loss / max(len(y_true), 1)
|
| 356 |
+
cm = confusion_matrix(y_true, y_pred, labels=list(range(NUM_CLASSES)))
|
| 357 |
+
|
| 358 |
+
return {
|
| 359 |
+
"loss": round(avg_loss, 4),
|
| 360 |
+
"accuracy": round(accuracy, 4),
|
| 361 |
+
"macro_f1": round(macro_f1, 4),
|
| 362 |
+
"disgust_f1": round(disgust_f1, 4),
|
| 363 |
+
"per_class_f1": per_class,
|
| 364 |
+
"confusion_matrix": cm.tolist(),
|
| 365 |
+
"y_true": y_true,
|
| 366 |
+
"y_pred": y_pred,
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 371 |
+
# Checkpoint
|
| 372 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 373 |
+
|
| 374 |
+
def save_lora_checkpoint(encoder, path: Path, epoch: int, metrics: dict,
|
| 375 |
+
best_f1: float = 0.0, patience_counter: int = 0,
|
| 376 |
+
optimizer=None, scheduler=None, scaler=None,
|
| 377 |
+
training_log=None):
|
| 378 |
+
"""Save LoRA weights + MLPHead + optimizer state for resume."""
|
| 379 |
+
lora_state = {}
|
| 380 |
+
for name, module in encoder.named_modules():
|
| 381 |
+
if isinstance(module, LoRALinear):
|
| 382 |
+
lora_state[f"{name}.lora_A.weight"] = module.lora_A.weight.data.cpu()
|
| 383 |
+
lora_state[f"{name}.lora_B.weight"] = module.lora_B.weight.data.cpu()
|
| 384 |
+
|
| 385 |
+
state = {
|
| 386 |
+
"lora_weights": lora_state,
|
| 387 |
+
"proj": encoder.proj.state_dict(),
|
| 388 |
+
"epoch": epoch,
|
| 389 |
+
"metrics": metrics,
|
| 390 |
+
"best_f1": best_f1,
|
| 391 |
+
"patience_counter": patience_counter,
|
| 392 |
+
"num_classes": NUM_CLASSES,
|
| 393 |
+
"labels": LABELS_7CLASS,
|
| 394 |
+
}
|
| 395 |
+
if optimizer is not None:
|
| 396 |
+
state["optimizer"] = optimizer.state_dict()
|
| 397 |
+
if scheduler is not None:
|
| 398 |
+
state["scheduler"] = scheduler.state_dict()
|
| 399 |
+
if scaler is not None:
|
| 400 |
+
state["scaler"] = scaler.state_dict()
|
| 401 |
+
if training_log is not None:
|
| 402 |
+
state["training_log"] = training_log
|
| 403 |
+
torch.save(state, str(path))
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def load_lora_checkpoint(encoder, path: Path, device: str,
|
| 407 |
+
optimizer=None, scheduler=None, scaler=None):
|
| 408 |
+
"""Load LoRA checkpoint and restore training state."""
|
| 409 |
+
logger.info("Resuming from checkpoint: %s", path)
|
| 410 |
+
ckpt = torch.load(str(path), map_location=device, weights_only=False)
|
| 411 |
+
|
| 412 |
+
# Restore LoRA weights
|
| 413 |
+
for name, module in encoder.named_modules():
|
| 414 |
+
if isinstance(module, LoRALinear):
|
| 415 |
+
a_key = f"{name}.lora_A.weight"
|
| 416 |
+
b_key = f"{name}.lora_B.weight"
|
| 417 |
+
if a_key in ckpt["lora_weights"]:
|
| 418 |
+
module.lora_A.weight.data.copy_(ckpt["lora_weights"][a_key].to(device))
|
| 419 |
+
module.lora_B.weight.data.copy_(ckpt["lora_weights"][b_key].to(device))
|
| 420 |
+
|
| 421 |
+
# Restore MLPHead
|
| 422 |
+
encoder.proj.load_state_dict(ckpt["proj"])
|
| 423 |
+
|
| 424 |
+
# Restore optimizer/scheduler/scaler if available
|
| 425 |
+
if optimizer is not None and "optimizer" in ckpt:
|
| 426 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 427 |
+
if scheduler is not None and "scheduler" in ckpt:
|
| 428 |
+
scheduler.load_state_dict(ckpt["scheduler"])
|
| 429 |
+
if scaler is not None and "scaler" in ckpt:
|
| 430 |
+
scaler.load_state_dict(ckpt["scaler"])
|
| 431 |
+
|
| 432 |
+
logger.info("Resumed from epoch %d (best_f1=%.4f, patience=%d)",
|
| 433 |
+
ckpt["epoch"], ckpt["best_f1"], ckpt["patience_counter"])
|
| 434 |
+
return {
|
| 435 |
+
"epoch": ckpt["epoch"],
|
| 436 |
+
"best_f1": ckpt["best_f1"],
|
| 437 |
+
"patience_counter": ckpt["patience_counter"],
|
| 438 |
+
"training_log": ckpt.get("training_log", []),
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 443 |
+
# Confusion Matrix Plot
|
| 444 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 445 |
+
|
| 446 |
+
def plot_confusion_matrix(cm, output_path: Path, epoch: int):
|
| 447 |
+
"""Save confusion matrix as PNG."""
|
| 448 |
+
try:
|
| 449 |
+
import matplotlib
|
| 450 |
+
matplotlib.use("Agg")
|
| 451 |
+
import matplotlib.pyplot as plt
|
| 452 |
+
import seaborn as sns
|
| 453 |
+
|
| 454 |
+
fig, ax = plt.subplots(figsize=(9, 7))
|
| 455 |
+
cm_norm = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
|
| 456 |
+
sns.heatmap(
|
| 457 |
+
cm_norm, annot=True, fmt=".2f", cmap="Blues",
|
| 458 |
+
xticklabels=LABELS_7CLASS, yticklabels=LABELS_7CLASS, ax=ax,
|
| 459 |
+
)
|
| 460 |
+
for i in range(NUM_CLASSES):
|
| 461 |
+
for j in range(NUM_CLASSES):
|
| 462 |
+
ax.text(j + 0.5, i + 0.7, f"({cm[i][j]})",
|
| 463 |
+
ha="center", va="center", fontsize=6, color="gray")
|
| 464 |
+
|
| 465 |
+
ax.set_xlabel("Predicted")
|
| 466 |
+
ax.set_ylabel("True")
|
| 467 |
+
ax.set_title(f"LoRA 7-Class Confusion Matrix (Epoch {epoch})")
|
| 468 |
+
plt.tight_layout()
|
| 469 |
+
plt.savefig(str(output_path), dpi=150)
|
| 470 |
+
plt.close()
|
| 471 |
+
logger.info("Confusion matrix saved to %s", output_path)
|
| 472 |
+
except ImportError:
|
| 473 |
+
logger.warning("matplotlib/seaborn not available โ skipping confusion matrix plot")
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 477 |
+
# Training
|
| 478 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 479 |
+
|
| 480 |
+
def train(args):
|
| 481 |
+
device = args.device
|
| 482 |
+
use_amp = (device == "cuda")
|
| 483 |
+
accumulate_steps = args.accumulate_steps
|
| 484 |
+
|
| 485 |
+
# Load model
|
| 486 |
+
encoder = load_model(device, r=args.lora_r, alpha=args.lora_alpha, dropout=args.lora_dropout)
|
| 487 |
+
|
| 488 |
+
# Log trainable vs total
|
| 489 |
+
trainable = sum(p.numel() for p in encoder.parameters() if p.requires_grad)
|
| 490 |
+
total = sum(p.numel() for p in encoder.parameters())
|
| 491 |
+
logger.info("Trainable: %dK / Total: %dK (%.1f%%)",
|
| 492 |
+
trainable // 1000, total // 1000, 100 * trainable / total)
|
| 493 |
+
|
| 494 |
+
# Datasets
|
| 495 |
+
train_dataset = EmotionDataset(
|
| 496 |
+
args.train_manifest,
|
| 497 |
+
max_duration_sec=8.0,
|
| 498 |
+
phone_augment_prob=args.phone_augment_prob,
|
| 499 |
+
noise_augment_prob=args.noise_augment_prob,
|
| 500 |
+
)
|
| 501 |
+
val_dataset = EmotionDataset(args.val_manifest, max_duration_sec=8.0)
|
| 502 |
+
|
| 503 |
+
logger.info("Train: %d samples, Val: %d samples", len(train_dataset), len(val_dataset))
|
| 504 |
+
logger.info("AMP: %s, Accumulate: %d, Effective batch: %d",
|
| 505 |
+
use_amp, accumulate_steps, args.batch_size * accumulate_steps)
|
| 506 |
+
|
| 507 |
+
n_workers = 2
|
| 508 |
+
use_pin = (device == "cuda")
|
| 509 |
+
train_loader = DataLoader(
|
| 510 |
+
train_dataset, batch_size=args.batch_size, shuffle=True,
|
| 511 |
+
collate_fn=collate_fn, num_workers=n_workers, drop_last=True,
|
| 512 |
+
pin_memory=use_pin, persistent_workers=(n_workers > 0),
|
| 513 |
+
)
|
| 514 |
+
val_loader = DataLoader(
|
| 515 |
+
val_dataset, batch_size=args.batch_size, shuffle=False,
|
| 516 |
+
collate_fn=collate_fn, num_workers=n_workers,
|
| 517 |
+
pin_memory=use_pin, persistent_workers=(n_workers > 0),
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
# Class weights: inverse frequency + disgust 2.5x boost
|
| 521 |
+
class_counts = np.zeros(NUM_CLASSES)
|
| 522 |
+
for sample in train_dataset.samples:
|
| 523 |
+
class_counts[LABEL2IDX[sample["label"]]] += 1
|
| 524 |
+
class_weights = 1.0 / np.maximum(class_counts, 1)
|
| 525 |
+
class_weights = class_weights / class_weights.sum() * NUM_CLASSES
|
| 526 |
+
class_weights[DISGUST_IDX] *= 2.5 # Disgust boost
|
| 527 |
+
logger.info("Class weights: %s",
|
| 528 |
+
{LABELS_7CLASS[i]: round(float(class_weights[i]), 3) for i in range(NUM_CLASSES)})
|
| 529 |
+
|
| 530 |
+
criterion = FocalLoss(
|
| 531 |
+
weight=torch.tensor(class_weights, dtype=torch.float32).to(device),
|
| 532 |
+
gamma=2.0,
|
| 533 |
+
label_smoothing=0.05,
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
# Optimizer: differential LR
|
| 537 |
+
lora_params = []
|
| 538 |
+
proj_params = list(encoder.proj.parameters())
|
| 539 |
+
proj_ids = {id(p) for p in proj_params}
|
| 540 |
+
for name, param in encoder.named_parameters():
|
| 541 |
+
if param.requires_grad and id(param) not in proj_ids:
|
| 542 |
+
lora_params.append(param)
|
| 543 |
+
|
| 544 |
+
optimizer = torch.optim.AdamW([
|
| 545 |
+
{"params": lora_params, "lr": args.lora_lr},
|
| 546 |
+
{"params": proj_params, "lr": args.proj_lr},
|
| 547 |
+
], weight_decay=args.weight_decay)
|
| 548 |
+
|
| 549 |
+
# OneCycleLR scheduler
|
| 550 |
+
steps_per_epoch = max(len(train_loader) // accumulate_steps, 1)
|
| 551 |
+
total_steps = steps_per_epoch * args.epochs
|
| 552 |
+
scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| 553 |
+
optimizer,
|
| 554 |
+
max_lr=[args.lora_lr, args.proj_lr],
|
| 555 |
+
total_steps=total_steps,
|
| 556 |
+
pct_start=0.1,
|
| 557 |
+
anneal_strategy="cos",
|
| 558 |
+
)
|
| 559 |
+
|
| 560 |
+
# Output dir
|
| 561 |
+
output_dir = Path(args.output_dir)
|
| 562 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 563 |
+
|
| 564 |
+
# AMP scaler
|
| 565 |
+
scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
|
| 566 |
+
|
| 567 |
+
# Training state
|
| 568 |
+
training_log = []
|
| 569 |
+
best_f1 = 0.0
|
| 570 |
+
patience_counter = 0
|
| 571 |
+
start_epoch = 1
|
| 572 |
+
disgust_gate = 0.3
|
| 573 |
+
|
| 574 |
+
# Resume from checkpoint if requested
|
| 575 |
+
if args.resume:
|
| 576 |
+
resume_path = output_dir / "last_lora.pt"
|
| 577 |
+
if resume_path.exists():
|
| 578 |
+
resume_state = load_lora_checkpoint(
|
| 579 |
+
encoder, resume_path, device,
|
| 580 |
+
optimizer=optimizer, scheduler=scheduler, scaler=scaler,
|
| 581 |
+
)
|
| 582 |
+
start_epoch = resume_state["epoch"] + 1
|
| 583 |
+
best_f1 = resume_state["best_f1"]
|
| 584 |
+
patience_counter = resume_state["patience_counter"]
|
| 585 |
+
training_log = resume_state["training_log"]
|
| 586 |
+
logger.info("Resuming training from epoch %d (best_f1=%.4f)", start_epoch, best_f1)
|
| 587 |
+
else:
|
| 588 |
+
logger.warning("--resume set but no checkpoint found at %s, starting fresh", resume_path)
|
| 589 |
+
|
| 590 |
+
for epoch in range(start_epoch, args.epochs + 1):
|
| 591 |
+
epoch_start = time.time()
|
| 592 |
+
encoder.train()
|
| 593 |
+
total_loss = 0
|
| 594 |
+
correct = 0
|
| 595 |
+
total_samples = 0
|
| 596 |
+
optimizer.zero_grad()
|
| 597 |
+
|
| 598 |
+
for batch_idx, (waveforms, labels) in enumerate(train_loader):
|
| 599 |
+
labels = labels.to(device)
|
| 600 |
+
|
| 601 |
+
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 602 |
+
logits = forward_pass(encoder, waveforms, device)
|
| 603 |
+
loss = criterion(logits, labels) / accumulate_steps
|
| 604 |
+
|
| 605 |
+
scaler.scale(loss).backward()
|
| 606 |
+
|
| 607 |
+
if (batch_idx + 1) % accumulate_steps == 0 or (batch_idx + 1) == len(train_loader):
|
| 608 |
+
scaler.unscale_(optimizer)
|
| 609 |
+
trainable_params = [p for p in encoder.parameters() if p.requires_grad]
|
| 610 |
+
torch.nn.utils.clip_grad_norm_(trainable_params, 1.0)
|
| 611 |
+
scaler.step(optimizer)
|
| 612 |
+
scaler.update()
|
| 613 |
+
optimizer.zero_grad()
|
| 614 |
+
scheduler.step()
|
| 615 |
+
|
| 616 |
+
total_loss += loss.item() * accumulate_steps * labels.size(0)
|
| 617 |
+
preds = logits.argmax(dim=-1)
|
| 618 |
+
correct += (preds == labels).sum().item()
|
| 619 |
+
total_samples += labels.size(0)
|
| 620 |
+
|
| 621 |
+
if (batch_idx + 1) % 10 == 0:
|
| 622 |
+
cur_lr = optimizer.param_groups[0]["lr"]
|
| 623 |
+
logger.info(" Epoch %d [%d/%d] loss=%.4f lr=%.2e",
|
| 624 |
+
epoch, batch_idx + 1, len(train_loader),
|
| 625 |
+
loss.item() * accumulate_steps, cur_lr)
|
| 626 |
+
|
| 627 |
+
train_loss = total_loss / max(total_samples, 1)
|
| 628 |
+
train_acc = correct / max(total_samples, 1)
|
| 629 |
+
|
| 630 |
+
# Validate
|
| 631 |
+
val_metrics = validate(encoder, val_loader, device, criterion)
|
| 632 |
+
|
| 633 |
+
epoch_time = time.time() - epoch_start
|
| 634 |
+
logger.info(
|
| 635 |
+
"Epoch %d/%d (%.0fs): train_loss=%.4f train_acc=%.3f | "
|
| 636 |
+
"val_loss=%.4f val_f1=%.3f val_acc=%.3f disgust_f1=%.3f",
|
| 637 |
+
epoch, args.epochs, epoch_time,
|
| 638 |
+
train_loss, train_acc,
|
| 639 |
+
val_metrics["loss"], val_metrics["macro_f1"],
|
| 640 |
+
val_metrics["accuracy"], val_metrics["disgust_f1"],
|
| 641 |
+
)
|
| 642 |
+
logger.info(" Per-class F1: %s", val_metrics["per_class_f1"])
|
| 643 |
+
|
| 644 |
+
epoch_log = {
|
| 645 |
+
"epoch": epoch,
|
| 646 |
+
"train_loss": round(train_loss, 4),
|
| 647 |
+
"train_acc": round(train_acc, 4),
|
| 648 |
+
"val_loss": val_metrics["loss"],
|
| 649 |
+
"val_accuracy": val_metrics["accuracy"],
|
| 650 |
+
"val_macro_f1": val_metrics["macro_f1"],
|
| 651 |
+
"val_disgust_f1": val_metrics["disgust_f1"],
|
| 652 |
+
"val_per_class_f1": val_metrics["per_class_f1"],
|
| 653 |
+
"epoch_time_sec": round(epoch_time, 1),
|
| 654 |
+
}
|
| 655 |
+
training_log.append(epoch_log)
|
| 656 |
+
|
| 657 |
+
# Disgust F1 Gate + best model save
|
| 658 |
+
gate_pass = val_metrics["disgust_f1"] >= disgust_gate
|
| 659 |
+
if not gate_pass:
|
| 660 |
+
logger.warning("[GATE FAIL] disgust_f1=%.3f < %.1f โ not saving as best",
|
| 661 |
+
val_metrics["disgust_f1"], disgust_gate)
|
| 662 |
+
|
| 663 |
+
if val_metrics["macro_f1"] > best_f1 and gate_pass:
|
| 664 |
+
best_f1 = val_metrics["macro_f1"]
|
| 665 |
+
patience_counter = 0
|
| 666 |
+
save_lora_checkpoint(
|
| 667 |
+
encoder, output_dir / "best_lora.pt", epoch, val_metrics, best_f1,
|
| 668 |
+
optimizer=optimizer, scheduler=scheduler, scaler=scaler,
|
| 669 |
+
training_log=training_log,
|
| 670 |
+
)
|
| 671 |
+
logger.info(" New best! macro_f1=%.4f (gate passed) saved to best_lora.pt", best_f1)
|
| 672 |
+
|
| 673 |
+
if "confusion_matrix" in val_metrics:
|
| 674 |
+
cm = np.array(val_metrics["confusion_matrix"])
|
| 675 |
+
plot_confusion_matrix(cm, output_dir / f"confusion_matrix_epoch{epoch}.png", epoch)
|
| 676 |
+
elif gate_pass:
|
| 677 |
+
patience_counter += 1
|
| 678 |
+
else:
|
| 679 |
+
# Gate fail does not increment patience
|
| 680 |
+
pass
|
| 681 |
+
|
| 682 |
+
# Always save last (with full state for resume)
|
| 683 |
+
save_lora_checkpoint(
|
| 684 |
+
encoder, output_dir / "last_lora.pt", epoch, val_metrics, best_f1, patience_counter,
|
| 685 |
+
optimizer=optimizer, scheduler=scheduler, scaler=scaler,
|
| 686 |
+
training_log=training_log,
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
# Save training log
|
| 690 |
+
with open(output_dir / "training_log.json", "w") as f:
|
| 691 |
+
json.dump(training_log, f, indent=2)
|
| 692 |
+
|
| 693 |
+
# Early stopping (only on gate-passing epochs)
|
| 694 |
+
if patience_counter >= args.patience:
|
| 695 |
+
logger.info("Early stopping at epoch %d (patience=%d)", epoch, args.patience)
|
| 696 |
+
break
|
| 697 |
+
|
| 698 |
+
if device == "cuda":
|
| 699 |
+
torch.cuda.empty_cache()
|
| 700 |
+
|
| 701 |
+
# Save config
|
| 702 |
+
config = {
|
| 703 |
+
"model": "iic/emotion2vec_plus_base",
|
| 704 |
+
"method": "LoRA",
|
| 705 |
+
"lora_r": args.lora_r,
|
| 706 |
+
"lora_alpha": args.lora_alpha,
|
| 707 |
+
"num_classes": NUM_CLASSES,
|
| 708 |
+
"labels": LABELS_7CLASS,
|
| 709 |
+
"label2idx": LABEL2IDX,
|
| 710 |
+
"best_val_f1": best_f1,
|
| 711 |
+
"training_args": vars(args),
|
| 712 |
+
}
|
| 713 |
+
with open(output_dir / "config.json", "w") as f:
|
| 714 |
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
| 715 |
+
|
| 716 |
+
logger.info("Training complete. Best F1=%.4f at %s", best_f1, output_dir / "best_lora.pt")
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 720 |
+
# Main
|
| 721 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 722 |
+
|
| 723 |
+
def main():
|
| 724 |
+
parser = argparse.ArgumentParser(description="LoRA fine-tune emotion2vec 7-class")
|
| 725 |
+
parser.add_argument("--train-manifest", required=True)
|
| 726 |
+
parser.add_argument("--val-manifest", required=True)
|
| 727 |
+
parser.add_argument("--output-dir", default="data/models/lora_emotion2vec_7class")
|
| 728 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 729 |
+
parser.add_argument("--epochs", type=int, default=20)
|
| 730 |
+
parser.add_argument("--batch-size", type=int, default=4)
|
| 731 |
+
parser.add_argument("--accumulate-steps", type=int, default=8)
|
| 732 |
+
parser.add_argument("--lora-r", type=int, default=16)
|
| 733 |
+
parser.add_argument("--lora-alpha", type=int, default=32)
|
| 734 |
+
parser.add_argument("--lora-dropout", type=float, default=0.1)
|
| 735 |
+
parser.add_argument("--lora-lr", type=float, default=2e-4)
|
| 736 |
+
parser.add_argument("--proj-lr", type=float, default=2e-3)
|
| 737 |
+
parser.add_argument("--weight-decay", type=float, default=0.01)
|
| 738 |
+
parser.add_argument("--patience", type=int, default=7)
|
| 739 |
+
parser.add_argument("--phone-augment-prob", type=float, default=0.3)
|
| 740 |
+
parser.add_argument("--noise-augment-prob", type=float, default=0.15)
|
| 741 |
+
parser.add_argument("--resume", action="store_true",
|
| 742 |
+
help="Resume from last_lora.pt checkpoint in output-dir")
|
| 743 |
+
args = parser.parse_args()
|
| 744 |
+
|
| 745 |
+
train(args)
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
if __name__ == "__main__":
|
| 749 |
+
main()
|
scripts/train_lora_kcelectra.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""LoRA Fine-Tuning KcELECTRA for 7-Class Korean Text Emotion Recognition.
|
| 3 |
+
|
| 4 |
+
Uses PEFT LoRA on beomi/KcELECTRA-base-v2022 for text-based emotion classification.
|
| 5 |
+
Filters out samples without text (e.g., RAVDESS English data).
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python scripts/train_lora_kcelectra.py \
|
| 9 |
+
--train-manifest data/lora_dataset/train_manifest.json \
|
| 10 |
+
--val-manifest data/lora_dataset/val_manifest.json \
|
| 11 |
+
--output-dir data/models/lora_kcelectra_7class \
|
| 12 |
+
--epochs 10 --batch-size 16 --device cuda
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
import random
|
| 20 |
+
import time
|
| 21 |
+
from collections import Counter
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
from torch.utils.data import DataLoader, Dataset
|
| 28 |
+
|
| 29 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
LABELS_7CLASS = ["happiness", "anger", "disgust", "fear", "neutral", "sadness", "surprise"]
|
| 33 |
+
LABEL2IDX = {l: i for i, l in enumerate(LABELS_7CLASS)}
|
| 34 |
+
NUM_CLASSES = len(LABELS_7CLASS)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 38 |
+
# Dataset
|
| 39 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 40 |
+
|
| 41 |
+
class TextEmotionDataset(Dataset):
|
| 42 |
+
"""Load text + label from manifest, skip samples without text."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, manifest_path: str, tokenizer, max_length: int = 128):
|
| 45 |
+
with open(manifest_path, encoding="utf-8") as f:
|
| 46 |
+
raw = json.load(f)
|
| 47 |
+
|
| 48 |
+
# Filter: only samples with non-empty Korean text
|
| 49 |
+
self.samples = [
|
| 50 |
+
s for s in raw
|
| 51 |
+
if s.get("text", "").strip() and s["label"] in LABEL2IDX
|
| 52 |
+
]
|
| 53 |
+
self.tokenizer = tokenizer
|
| 54 |
+
self.max_length = max_length
|
| 55 |
+
|
| 56 |
+
logger.info("TextEmotionDataset: %d samples (filtered from %d, skipped %d without text)",
|
| 57 |
+
len(self.samples), len(raw), len(raw) - len(self.samples))
|
| 58 |
+
|
| 59 |
+
def __len__(self):
|
| 60 |
+
return len(self.samples)
|
| 61 |
+
|
| 62 |
+
def __getitem__(self, idx):
|
| 63 |
+
sample = self.samples[idx]
|
| 64 |
+
text = sample["text"]
|
| 65 |
+
label = LABEL2IDX[sample["label"]]
|
| 66 |
+
|
| 67 |
+
encoding = self.tokenizer(
|
| 68 |
+
text,
|
| 69 |
+
truncation=True,
|
| 70 |
+
max_length=self.max_length,
|
| 71 |
+
padding="max_length",
|
| 72 |
+
return_tensors="pt",
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"input_ids": encoding["input_ids"].squeeze(0),
|
| 77 |
+
"attention_mask": encoding["attention_mask"].squeeze(0),
|
| 78 |
+
"labels": torch.tensor(label, dtype=torch.long),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 83 |
+
# Validation
|
| 84 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 85 |
+
|
| 86 |
+
@torch.no_grad()
|
| 87 |
+
def validate(model, val_loader, device, criterion):
|
| 88 |
+
model.eval()
|
| 89 |
+
total_loss = 0
|
| 90 |
+
y_true, y_pred = [], []
|
| 91 |
+
|
| 92 |
+
for batch in val_loader:
|
| 93 |
+
input_ids = batch["input_ids"].to(device)
|
| 94 |
+
attention_mask = batch["attention_mask"].to(device)
|
| 95 |
+
labels = batch["labels"].to(device)
|
| 96 |
+
|
| 97 |
+
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
|
| 98 |
+
logits = outputs.logits
|
| 99 |
+
loss = criterion(logits, labels)
|
| 100 |
+
|
| 101 |
+
total_loss += loss.item() * labels.size(0)
|
| 102 |
+
y_true.extend(labels.cpu().tolist())
|
| 103 |
+
y_pred.extend(logits.argmax(dim=-1).cpu().tolist())
|
| 104 |
+
|
| 105 |
+
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix
|
| 106 |
+
acc = accuracy_score(y_true, y_pred)
|
| 107 |
+
f1_per_class = f1_score(y_true, y_pred, labels=list(range(NUM_CLASSES)),
|
| 108 |
+
average=None, zero_division=0)
|
| 109 |
+
macro_f1 = float(np.mean(f1_per_class))
|
| 110 |
+
per_class = {LABELS_7CLASS[i]: round(float(f1_per_class[i]), 4) for i in range(NUM_CLASSES)}
|
| 111 |
+
cm = confusion_matrix(y_true, y_pred, labels=list(range(NUM_CLASSES)))
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"loss": round(total_loss / max(len(y_true), 1), 4),
|
| 115 |
+
"accuracy": round(acc, 4),
|
| 116 |
+
"macro_f1": round(macro_f1, 4),
|
| 117 |
+
"per_class_f1": per_class,
|
| 118 |
+
"confusion_matrix": cm.tolist(),
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def plot_confusion_matrix(cm, output_path: Path, epoch: int):
|
| 123 |
+
try:
|
| 124 |
+
import matplotlib; matplotlib.use("Agg")
|
| 125 |
+
import matplotlib.pyplot as plt
|
| 126 |
+
import seaborn as sns
|
| 127 |
+
fig, ax = plt.subplots(figsize=(9, 7))
|
| 128 |
+
cm_norm = cm / cm.sum(axis=1, keepdims=True)
|
| 129 |
+
sns.heatmap(cm_norm, annot=True, fmt=".2f", cmap="Blues",
|
| 130 |
+
xticklabels=LABELS_7CLASS, yticklabels=LABELS_7CLASS, ax=ax)
|
| 131 |
+
for i in range(NUM_CLASSES):
|
| 132 |
+
for j in range(NUM_CLASSES):
|
| 133 |
+
ax.text(j + 0.5, i + 0.7, f"({cm[i][j]})",
|
| 134 |
+
ha="center", va="center", fontsize=6, color="gray")
|
| 135 |
+
ax.set_xlabel("Predicted"); ax.set_ylabel("True")
|
| 136 |
+
ax.set_title(f"KcELECTRA LoRA โ Epoch {epoch}")
|
| 137 |
+
plt.tight_layout(); plt.savefig(str(output_path), dpi=150); plt.close()
|
| 138 |
+
except ImportError:
|
| 139 |
+
pass
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 143 |
+
# Training
|
| 144 |
+
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 145 |
+
|
| 146 |
+
def train(args):
|
| 147 |
+
device = args.device
|
| 148 |
+
use_amp = (device == "cuda")
|
| 149 |
+
|
| 150 |
+
# Load tokenizer + base model
|
| 151 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 152 |
+
from peft import LoraConfig, get_peft_model, TaskType
|
| 153 |
+
|
| 154 |
+
logger.info("Loading KcELECTRA: %s", args.model_id)
|
| 155 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_id)
|
| 156 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 157 |
+
args.model_id,
|
| 158 |
+
num_labels=NUM_CLASSES,
|
| 159 |
+
id2label={i: l for i, l in enumerate(LABELS_7CLASS)},
|
| 160 |
+
label2id=LABEL2IDX,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Apply LoRA
|
| 164 |
+
lora_config = LoraConfig(
|
| 165 |
+
r=args.lora_r,
|
| 166 |
+
lora_alpha=args.lora_alpha,
|
| 167 |
+
lora_dropout=args.lora_dropout,
|
| 168 |
+
target_modules=["query", "value"],
|
| 169 |
+
task_type=TaskType.SEQ_CLS,
|
| 170 |
+
bias="none",
|
| 171 |
+
)
|
| 172 |
+
model = get_peft_model(model, lora_config)
|
| 173 |
+
model.print_trainable_parameters()
|
| 174 |
+
model = model.to(device)
|
| 175 |
+
|
| 176 |
+
# Datasets
|
| 177 |
+
train_ds = TextEmotionDataset(args.train_manifest, tokenizer, max_length=args.max_length)
|
| 178 |
+
val_ds = TextEmotionDataset(args.val_manifest, tokenizer, max_length=args.max_length)
|
| 179 |
+
logger.info("Train: %d, Val: %d", len(train_ds), len(val_ds))
|
| 180 |
+
|
| 181 |
+
train_loader = DataLoader(
|
| 182 |
+
train_ds, batch_size=args.batch_size, shuffle=True,
|
| 183 |
+
num_workers=2, pin_memory=(device == "cuda"), drop_last=True,
|
| 184 |
+
)
|
| 185 |
+
val_loader = DataLoader(
|
| 186 |
+
val_ds, batch_size=args.batch_size, shuffle=False,
|
| 187 |
+
num_workers=2, pin_memory=(device == "cuda"),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Class weights: inverse frequency only (no extra boost)
|
| 191 |
+
class_counts = np.zeros(NUM_CLASSES)
|
| 192 |
+
for s in train_ds.samples:
|
| 193 |
+
class_counts[LABEL2IDX[s["label"]]] += 1
|
| 194 |
+
class_weights = 1.0 / np.maximum(class_counts, 1)
|
| 195 |
+
class_weights = class_weights / class_weights.sum() * NUM_CLASSES
|
| 196 |
+
logger.info("Class weights: %s",
|
| 197 |
+
{LABELS_7CLASS[i]: round(float(class_weights[i]), 3) for i in range(NUM_CLASSES)})
|
| 198 |
+
|
| 199 |
+
criterion = nn.CrossEntropyLoss(
|
| 200 |
+
weight=torch.tensor(class_weights, dtype=torch.float32).to(device),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Optimizer
|
| 204 |
+
optimizer = torch.optim.AdamW(
|
| 205 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 206 |
+
lr=args.lr,
|
| 207 |
+
weight_decay=args.weight_decay,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Scheduler
|
| 211 |
+
steps_per_epoch = max(len(train_loader) // args.accumulate_steps, 1)
|
| 212 |
+
total_steps = steps_per_epoch * args.epochs
|
| 213 |
+
scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| 214 |
+
optimizer, max_lr=args.lr, total_steps=total_steps,
|
| 215 |
+
pct_start=0.1, anneal_strategy="cos",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
scaler = torch.amp.GradScaler("cuda", enabled=use_amp)
|
| 219 |
+
output_dir = Path(args.output_dir)
|
| 220 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 221 |
+
|
| 222 |
+
# Training state
|
| 223 |
+
training_log = []
|
| 224 |
+
best_f1 = 0.0
|
| 225 |
+
patience_counter = 0
|
| 226 |
+
start_epoch = 1
|
| 227 |
+
|
| 228 |
+
# Resume
|
| 229 |
+
if args.resume:
|
| 230 |
+
ckpt_path = output_dir / "last_checkpoint.json"
|
| 231 |
+
if ckpt_path.exists():
|
| 232 |
+
with open(ckpt_path) as f:
|
| 233 |
+
ckpt_info = json.load(f)
|
| 234 |
+
start_epoch = ckpt_info["epoch"] + 1
|
| 235 |
+
best_f1 = ckpt_info["best_f1"]
|
| 236 |
+
patience_counter = ckpt_info["patience_counter"]
|
| 237 |
+
training_log = ckpt_info.get("training_log", [])
|
| 238 |
+
# Load model weights
|
| 239 |
+
model_path = output_dir / "last_model"
|
| 240 |
+
if model_path.exists():
|
| 241 |
+
from peft import PeftModel
|
| 242 |
+
model = AutoModelForSequenceClassification.from_pretrained(
|
| 243 |
+
args.model_id, num_labels=NUM_CLASSES,
|
| 244 |
+
id2label={i: l for i, l in enumerate(LABELS_7CLASS)},
|
| 245 |
+
label2id=LABEL2IDX,
|
| 246 |
+
)
|
| 247 |
+
model = PeftModel.from_pretrained(model, str(model_path))
|
| 248 |
+
model = model.to(device)
|
| 249 |
+
# Rebuild optimizer
|
| 250 |
+
optimizer = torch.optim.AdamW(
|
| 251 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 252 |
+
lr=args.lr, weight_decay=args.weight_decay,
|
| 253 |
+
)
|
| 254 |
+
remaining_steps = steps_per_epoch * (args.epochs - start_epoch + 1)
|
| 255 |
+
scheduler = torch.optim.lr_scheduler.OneCycleLR(
|
| 256 |
+
optimizer, max_lr=args.lr, total_steps=max(remaining_steps, 1),
|
| 257 |
+
pct_start=0.1, anneal_strategy="cos",
|
| 258 |
+
)
|
| 259 |
+
logger.info("Resumed from epoch %d (best_f1=%.4f)", start_epoch, best_f1)
|
| 260 |
+
else:
|
| 261 |
+
logger.warning("--resume but no checkpoint found, starting fresh")
|
| 262 |
+
|
| 263 |
+
for epoch in range(start_epoch, args.epochs + 1):
|
| 264 |
+
epoch_start = time.time()
|
| 265 |
+
model.train()
|
| 266 |
+
total_loss = 0; correct = 0; total_samples = 0
|
| 267 |
+
optimizer.zero_grad()
|
| 268 |
+
|
| 269 |
+
for batch_idx, batch in enumerate(train_loader):
|
| 270 |
+
input_ids = batch["input_ids"].to(device)
|
| 271 |
+
attention_mask = batch["attention_mask"].to(device)
|
| 272 |
+
labels = batch["labels"].to(device)
|
| 273 |
+
|
| 274 |
+
with torch.amp.autocast("cuda", enabled=use_amp):
|
| 275 |
+
outputs = model(input_ids=input_ids, attention_mask=attention_mask)
|
| 276 |
+
logits = outputs.logits
|
| 277 |
+
loss = criterion(logits, labels) / args.accumulate_steps
|
| 278 |
+
|
| 279 |
+
scaler.scale(loss).backward()
|
| 280 |
+
|
| 281 |
+
if (batch_idx + 1) % args.accumulate_steps == 0 or (batch_idx + 1) == len(train_loader):
|
| 282 |
+
scaler.unscale_(optimizer)
|
| 283 |
+
torch.nn.utils.clip_grad_norm_(
|
| 284 |
+
[p for p in model.parameters() if p.requires_grad], 1.0,
|
| 285 |
+
)
|
| 286 |
+
scaler.step(optimizer); scaler.update()
|
| 287 |
+
optimizer.zero_grad(); scheduler.step()
|
| 288 |
+
|
| 289 |
+
total_loss += loss.item() * args.accumulate_steps * labels.size(0)
|
| 290 |
+
preds = logits.argmax(dim=-1)
|
| 291 |
+
correct += (preds == labels).sum().item()
|
| 292 |
+
total_samples += labels.size(0)
|
| 293 |
+
|
| 294 |
+
if (batch_idx + 1) % 50 == 0:
|
| 295 |
+
logger.info(" Epoch %d [%d/%d] loss=%.4f lr=%.2e",
|
| 296 |
+
epoch, batch_idx + 1, len(train_loader),
|
| 297 |
+
loss.item() * args.accumulate_steps,
|
| 298 |
+
optimizer.param_groups[0]["lr"])
|
| 299 |
+
|
| 300 |
+
train_loss = total_loss / max(total_samples, 1)
|
| 301 |
+
train_acc = correct / max(total_samples, 1)
|
| 302 |
+
|
| 303 |
+
# Validate
|
| 304 |
+
val_metrics = validate(model, val_loader, device, criterion)
|
| 305 |
+
epoch_time = time.time() - epoch_start
|
| 306 |
+
|
| 307 |
+
logger.info(
|
| 308 |
+
"Epoch %d/%d (%.0fs): train_loss=%.4f train_acc=%.3f | "
|
| 309 |
+
"val_loss=%.4f val_f1=%.3f val_acc=%.3f",
|
| 310 |
+
epoch, args.epochs, epoch_time, train_loss, train_acc,
|
| 311 |
+
val_metrics["loss"], val_metrics["macro_f1"], val_metrics["accuracy"],
|
| 312 |
+
)
|
| 313 |
+
logger.info(" Per-class F1: %s", val_metrics["per_class_f1"])
|
| 314 |
+
|
| 315 |
+
training_log.append({
|
| 316 |
+
"epoch": epoch,
|
| 317 |
+
"train_loss": round(train_loss, 4),
|
| 318 |
+
"train_acc": round(train_acc, 4),
|
| 319 |
+
**{f"val_{k}": v for k, v in val_metrics.items() if k != "confusion_matrix"},
|
| 320 |
+
"epoch_time_sec": round(epoch_time, 1),
|
| 321 |
+
})
|
| 322 |
+
|
| 323 |
+
# Best model
|
| 324 |
+
if val_metrics["macro_f1"] > best_f1:
|
| 325 |
+
best_f1 = val_metrics["macro_f1"]
|
| 326 |
+
patience_counter = 0
|
| 327 |
+
model.save_pretrained(str(output_dir / "best_model"))
|
| 328 |
+
tokenizer.save_pretrained(str(output_dir / "best_model"))
|
| 329 |
+
logger.info(" New best! macro_f1=%.4f saved to best_model/", best_f1)
|
| 330 |
+
if "confusion_matrix" in val_metrics:
|
| 331 |
+
cm = np.array(val_metrics["confusion_matrix"])
|
| 332 |
+
plot_confusion_matrix(cm, output_dir / f"cm_epoch{epoch}.png", epoch)
|
| 333 |
+
else:
|
| 334 |
+
patience_counter += 1
|
| 335 |
+
|
| 336 |
+
# Save last (for resume)
|
| 337 |
+
model.save_pretrained(str(output_dir / "last_model"))
|
| 338 |
+
tokenizer.save_pretrained(str(output_dir / "last_model"))
|
| 339 |
+
with open(output_dir / "last_checkpoint.json", "w") as f:
|
| 340 |
+
json.dump({
|
| 341 |
+
"epoch": epoch, "best_f1": best_f1,
|
| 342 |
+
"patience_counter": patience_counter,
|
| 343 |
+
"training_log": training_log,
|
| 344 |
+
}, f, indent=2)
|
| 345 |
+
|
| 346 |
+
with open(output_dir / "training_log.json", "w") as f:
|
| 347 |
+
json.dump(training_log, f, indent=2)
|
| 348 |
+
|
| 349 |
+
if patience_counter >= args.patience:
|
| 350 |
+
logger.info("Early stopping at epoch %d (patience=%d)", epoch, args.patience)
|
| 351 |
+
break
|
| 352 |
+
|
| 353 |
+
if device == "cuda":
|
| 354 |
+
torch.cuda.empty_cache()
|
| 355 |
+
|
| 356 |
+
# Save config
|
| 357 |
+
config = {
|
| 358 |
+
"base_model": args.model_id,
|
| 359 |
+
"method": "PEFT LoRA",
|
| 360 |
+
"lora_r": args.lora_r,
|
| 361 |
+
"lora_alpha": args.lora_alpha,
|
| 362 |
+
"target_modules": ["query", "value"],
|
| 363 |
+
"num_classes": NUM_CLASSES,
|
| 364 |
+
"labels": LABELS_7CLASS,
|
| 365 |
+
"best_val_f1": best_f1,
|
| 366 |
+
"training_args": vars(args),
|
| 367 |
+
}
|
| 368 |
+
with open(output_dir / "config.json", "w") as f:
|
| 369 |
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
| 370 |
+
|
| 371 |
+
logger.info("Training complete. Best F1=%.4f", best_f1)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def main():
|
| 375 |
+
parser = argparse.ArgumentParser(description="LoRA fine-tune KcELECTRA 7-class")
|
| 376 |
+
parser.add_argument("--train-manifest", required=True)
|
| 377 |
+
parser.add_argument("--val-manifest", required=True)
|
| 378 |
+
parser.add_argument("--output-dir", default="data/models/lora_kcelectra_7class")
|
| 379 |
+
parser.add_argument("--model-id", default="beomi/KcELECTRA-base-v2022")
|
| 380 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 381 |
+
parser.add_argument("--epochs", type=int, default=10)
|
| 382 |
+
parser.add_argument("--batch-size", type=int, default=16)
|
| 383 |
+
parser.add_argument("--accumulate-steps", type=int, default=2)
|
| 384 |
+
parser.add_argument("--lr", type=float, default=2e-4)
|
| 385 |
+
parser.add_argument("--weight-decay", type=float, default=0.01)
|
| 386 |
+
parser.add_argument("--patience", type=int, default=3)
|
| 387 |
+
parser.add_argument("--max-length", type=int, default=128)
|
| 388 |
+
parser.add_argument("--lora-r", type=int, default=16)
|
| 389 |
+
parser.add_argument("--lora-alpha", type=int, default=32)
|
| 390 |
+
parser.add_argument("--lora-dropout", type=float, default=0.1)
|
| 391 |
+
parser.add_argument("--resume", action="store_true")
|
| 392 |
+
args = parser.parse_args()
|
| 393 |
+
|
| 394 |
+
torch.manual_seed(42)
|
| 395 |
+
random.seed(42)
|
| 396 |
+
np.random.seed(42)
|
| 397 |
+
|
| 398 |
+
train(args)
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
if __name__ == "__main__":
|
| 402 |
+
main()
|
scripts/train_whisper_emotion_head.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Whisper-Medium Encoder + Linear Emotion Head ํ์ต.
|
| 3 |
+
|
| 4 |
+
Whisper encoder๋ฅผ freezeํ๊ณ linear classifier head๋ง ํ์ตํ์ฌ
|
| 5 |
+
benchmark_ser_models.py์ WhisperMediumAdapter์ ์ฌ์ฉํ ์ฒดํฌํฌ์ธํธ๋ฅผ ์์ฑํ๋ค.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
# AI Hub ํ
์คํธ ์๋ธ์
์ธ์ ๋ฐ์ดํฐ๋ก ํ์ต (test leakage ๋ฐฉ์ง)
|
| 9 |
+
python scripts/train_whisper_emotion_head.py \\
|
| 10 |
+
--train-dir data/evaluation/korean/train_audio \\
|
| 11 |
+
--val-dir data/evaluation/korean/val_audio \\
|
| 12 |
+
--output data/models/whisper_emotion_head.pt
|
| 13 |
+
|
| 14 |
+
# prepare_aihub_test_subset.py์ ์ถ๋ ฅ์ผ๋ก quick test (test leakage ์ฃผ์)
|
| 15 |
+
python scripts/train_whisper_emotion_head.py \\
|
| 16 |
+
--train-dir data/evaluation/korean/test_audio \\
|
| 17 |
+
--epochs 3 --output data/models/whisper_emotion_head.pt
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import glob
|
| 24 |
+
import logging
|
| 25 |
+
import os
|
| 26 |
+
import time
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import torch
|
| 31 |
+
import torch.nn as nn
|
| 32 |
+
from torch.utils.data import DataLoader, Dataset
|
| 33 |
+
|
| 34 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
EVAL_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear"]
|
| 38 |
+
LABEL_TO_IDX = {label: i for i, label in enumerate(EVAL_LABELS)}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class EmotionAudioDataset(Dataset):
|
| 42 |
+
"""Load WAV files organized by emotion class directory."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, root_dir: str, processor, max_samples_per_class: int | None = None):
|
| 45 |
+
self.samples = []
|
| 46 |
+
self.processor = processor
|
| 47 |
+
|
| 48 |
+
for label in EVAL_LABELS:
|
| 49 |
+
class_dir = Path(root_dir) / label
|
| 50 |
+
if not class_dir.exists():
|
| 51 |
+
logger.warning("Class directory not found: %s", class_dir)
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
wavs = sorted(glob.glob(str(class_dir / "*.wav")))
|
| 55 |
+
if max_samples_per_class and len(wavs) > max_samples_per_class:
|
| 56 |
+
wavs = wavs[:max_samples_per_class]
|
| 57 |
+
|
| 58 |
+
for wav_path in wavs:
|
| 59 |
+
self.samples.append({
|
| 60 |
+
"path": wav_path,
|
| 61 |
+
"label": LABEL_TO_IDX[label],
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
logger.info("Dataset: %d samples from %s", len(self.samples), root_dir)
|
| 65 |
+
|
| 66 |
+
def __len__(self):
|
| 67 |
+
return len(self.samples)
|
| 68 |
+
|
| 69 |
+
def __getitem__(self, idx):
|
| 70 |
+
sample = self.samples[idx]
|
| 71 |
+
import librosa
|
| 72 |
+
audio, sr = librosa.load(sample["path"], sr=16000)
|
| 73 |
+
inputs = self.processor(audio, sampling_rate=16000, return_tensors="pt")
|
| 74 |
+
features = inputs.input_features.squeeze(0) # (n_mels, T)
|
| 75 |
+
return features, sample["label"]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def collate_fn(batch):
|
| 79 |
+
features, labels = zip(*batch)
|
| 80 |
+
# Pad features to same length
|
| 81 |
+
max_len = max(f.shape[1] for f in features)
|
| 82 |
+
padded = []
|
| 83 |
+
for f in features:
|
| 84 |
+
if f.shape[1] < max_len:
|
| 85 |
+
pad = torch.zeros(f.shape[0], max_len - f.shape[1])
|
| 86 |
+
f = torch.cat([f, pad], dim=1)
|
| 87 |
+
padded.append(f)
|
| 88 |
+
return torch.stack(padded), torch.tensor(labels, dtype=torch.long)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def train(args):
|
| 92 |
+
from transformers import WhisperModel, WhisperFeatureExtractor
|
| 93 |
+
|
| 94 |
+
device = torch.device(args.device)
|
| 95 |
+
|
| 96 |
+
# Load Whisper encoder (frozen)
|
| 97 |
+
logger.info("Loading Whisper-Medium encoder...")
|
| 98 |
+
processor = WhisperFeatureExtractor.from_pretrained("openai/whisper-medium")
|
| 99 |
+
whisper = WhisperModel.from_pretrained("openai/whisper-medium").to(device)
|
| 100 |
+
whisper.eval()
|
| 101 |
+
for param in whisper.parameters():
|
| 102 |
+
param.requires_grad = False
|
| 103 |
+
|
| 104 |
+
hidden_dim = whisper.config.d_model # 1024
|
| 105 |
+
head = nn.Linear(hidden_dim, len(EVAL_LABELS)).to(device)
|
| 106 |
+
|
| 107 |
+
# Dataset
|
| 108 |
+
train_dataset = EmotionAudioDataset(args.train_dir, processor, args.max_samples_per_class)
|
| 109 |
+
if len(train_dataset) == 0:
|
| 110 |
+
logger.error("No training samples found")
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
train_loader = DataLoader(
|
| 114 |
+
train_dataset, batch_size=args.batch_size, shuffle=True,
|
| 115 |
+
collate_fn=collate_fn, num_workers=0,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
val_loader = None
|
| 119 |
+
if args.val_dir and Path(args.val_dir).exists():
|
| 120 |
+
val_dataset = EmotionAudioDataset(args.val_dir, processor)
|
| 121 |
+
if len(val_dataset) > 0:
|
| 122 |
+
val_loader = DataLoader(
|
| 123 |
+
val_dataset, batch_size=args.batch_size, shuffle=False,
|
| 124 |
+
collate_fn=collate_fn, num_workers=0,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# Optimizer
|
| 128 |
+
optimizer = torch.optim.Adam(head.parameters(), lr=args.lr)
|
| 129 |
+
criterion = nn.CrossEntropyLoss()
|
| 130 |
+
|
| 131 |
+
# Training loop
|
| 132 |
+
best_val_acc = 0.0
|
| 133 |
+
for epoch in range(args.epochs):
|
| 134 |
+
head.train()
|
| 135 |
+
total_loss = 0
|
| 136 |
+
correct = 0
|
| 137 |
+
total = 0
|
| 138 |
+
|
| 139 |
+
for batch_idx, (features, labels) in enumerate(train_loader):
|
| 140 |
+
features = features.to(device)
|
| 141 |
+
labels = labels.to(device)
|
| 142 |
+
|
| 143 |
+
with torch.no_grad():
|
| 144 |
+
encoder_out = whisper.encoder(features)
|
| 145 |
+
hidden = encoder_out.last_hidden_state # (B, T, D)
|
| 146 |
+
pooled = hidden.mean(dim=1) # (B, D)
|
| 147 |
+
|
| 148 |
+
logits = head(pooled)
|
| 149 |
+
loss = criterion(logits, labels)
|
| 150 |
+
|
| 151 |
+
optimizer.zero_grad()
|
| 152 |
+
loss.backward()
|
| 153 |
+
optimizer.step()
|
| 154 |
+
|
| 155 |
+
total_loss += loss.item() * labels.size(0)
|
| 156 |
+
preds = logits.argmax(dim=1)
|
| 157 |
+
correct += (preds == labels).sum().item()
|
| 158 |
+
total += labels.size(0)
|
| 159 |
+
|
| 160 |
+
train_acc = correct / max(total, 1)
|
| 161 |
+
avg_loss = total_loss / max(total, 1)
|
| 162 |
+
logger.info("Epoch %d/%d: loss=%.4f, train_acc=%.3f",
|
| 163 |
+
epoch + 1, args.epochs, avg_loss, train_acc)
|
| 164 |
+
|
| 165 |
+
# Validation
|
| 166 |
+
if val_loader:
|
| 167 |
+
head.eval()
|
| 168 |
+
val_correct = 0
|
| 169 |
+
val_total = 0
|
| 170 |
+
with torch.no_grad():
|
| 171 |
+
for features, labels in val_loader:
|
| 172 |
+
features = features.to(device)
|
| 173 |
+
labels = labels.to(device)
|
| 174 |
+
encoder_out = whisper.encoder(features)
|
| 175 |
+
pooled = encoder_out.last_hidden_state.mean(dim=1)
|
| 176 |
+
logits = head(pooled)
|
| 177 |
+
preds = logits.argmax(dim=1)
|
| 178 |
+
val_correct += (preds == labels).sum().item()
|
| 179 |
+
val_total += labels.size(0)
|
| 180 |
+
val_acc = val_correct / max(val_total, 1)
|
| 181 |
+
logger.info(" val_acc=%.3f", val_acc)
|
| 182 |
+
|
| 183 |
+
if val_acc > best_val_acc:
|
| 184 |
+
best_val_acc = val_acc
|
| 185 |
+
save_checkpoint(head, args.output, epoch, val_acc)
|
| 186 |
+
else:
|
| 187 |
+
# No val set โ save latest
|
| 188 |
+
save_checkpoint(head, args.output, epoch, train_acc)
|
| 189 |
+
|
| 190 |
+
logger.info("Training complete. Best checkpoint: %s", args.output)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def save_checkpoint(head: nn.Linear, path: str, epoch: int, accuracy: float):
|
| 194 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
torch.save(head.state_dict(), path)
|
| 196 |
+
logger.info("Saved checkpoint: %s (epoch=%d, acc=%.3f)", path, epoch + 1, accuracy)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def main():
|
| 200 |
+
parser = argparse.ArgumentParser(description="Train Whisper emotion classifier head")
|
| 201 |
+
parser.add_argument("--train-dir", required=True,
|
| 202 |
+
help="ํ์ต ์ค๋์ค ๋๋ ํ ๋ฆฌ ({emotion}/*.wav ๊ตฌ์กฐ)")
|
| 203 |
+
parser.add_argument("--val-dir", default=None,
|
| 204 |
+
help="๊ฒ์ฆ ์ค๋์ค ๋๋ ํ ๋ฆฌ (์์ผ๋ฉด train accuracy๋ก ํ๋จ)")
|
| 205 |
+
parser.add_argument("--output", default="data/models/whisper_emotion_head.pt",
|
| 206 |
+
help="์ถ๋ ฅ ์ฒดํฌํฌ์ธํธ ๊ฒฝ๋ก")
|
| 207 |
+
parser.add_argument("--epochs", type=int, default=10)
|
| 208 |
+
parser.add_argument("--batch-size", type=int, default=8)
|
| 209 |
+
parser.add_argument("--lr", type=float, default=1e-3)
|
| 210 |
+
parser.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 211 |
+
parser.add_argument("--max-samples-per-class", type=int, default=None,
|
| 212 |
+
help="ํด๋์ค๋น ์ต๋ ํ์ต ์ํ ์")
|
| 213 |
+
args = parser.parse_args()
|
| 214 |
+
|
| 215 |
+
train(args)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
if __name__ == "__main__":
|
| 219 |
+
main()
|
scripts/validate_text_emotion_english.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""์์ด ํ
์คํธ ๊ฐ์ ๋ชจ๋ธ (DistilRoBERTa) sanity check.
|
| 3 |
+
|
| 4 |
+
hand-crafted ์์ด ๋ฌธ์ฅ์ผ๋ก ๋ชจ๋ธ ๋ก๋ฉ, ์ถ๋ ฅ ํฌ๋งท, ๊ธฐ๋ณธ ์ ํ๋๋ฅผ ๊ฒ์ฆํ๋ค.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/validate_text_emotion_english.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 17 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 18 |
+
|
| 19 |
+
logging.basicConfig(
|
| 20 |
+
level=logging.INFO,
|
| 21 |
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
| 22 |
+
)
|
| 23 |
+
logger = logging.getLogger("validate_text_en")
|
| 24 |
+
|
| 25 |
+
PROJECT_LABELS = ["neutral", "joy", "sadness", "anger", "surprise", "fear", "disgust"]
|
| 26 |
+
|
| 27 |
+
# (text, expected_emotion) โ ๋ช
ํํ ๊ฐ์ ํํ ๋ฌธ์ฅ
|
| 28 |
+
TEST_CASES = [
|
| 29 |
+
# joy
|
| 30 |
+
("I'm so happy today, everything is wonderful!", "joy"),
|
| 31 |
+
("This is the best day of my life!", "joy"),
|
| 32 |
+
("I'm thrilled about the good news!", "joy"),
|
| 33 |
+
# anger
|
| 34 |
+
("This makes me absolutely furious!", "anger"),
|
| 35 |
+
("I can't believe how unfair this is, I'm so angry!", "anger"),
|
| 36 |
+
("Stop doing that, it's driving me crazy!", "anger"),
|
| 37 |
+
# sadness
|
| 38 |
+
("I feel so sad and lonely right now.", "sadness"),
|
| 39 |
+
("I miss you so much, it hurts.", "sadness"),
|
| 40 |
+
("I can't stop crying, everything feels hopeless.", "sadness"),
|
| 41 |
+
# fear
|
| 42 |
+
("I'm really scared, something is wrong.", "fear"),
|
| 43 |
+
("I'm terrified of what might happen next.", "fear"),
|
| 44 |
+
("Help me, I'm so afraid!", "fear"),
|
| 45 |
+
# surprise
|
| 46 |
+
("Oh my god, I can't believe it!", "surprise"),
|
| 47 |
+
("Wow, I never expected that to happen!", "surprise"),
|
| 48 |
+
("What?! That's absolutely incredible!", "surprise"),
|
| 49 |
+
# neutral
|
| 50 |
+
("The meeting is scheduled for 3pm.", "neutral"),
|
| 51 |
+
("I need to buy groceries on the way home.", "neutral"),
|
| 52 |
+
("The temperature today is around 20 degrees.", "neutral"),
|
| 53 |
+
# disgust
|
| 54 |
+
("That's absolutely disgusting, I feel sick.", "disgust"),
|
| 55 |
+
("This food tastes terrible, it's revolting.", "disgust"),
|
| 56 |
+
("I can't stand the smell, it's nauseating.", "disgust"),
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def validate():
|
| 61 |
+
"""DistilRoBERTa ๋ชจ๋ธ ๊ฒ์ฆ."""
|
| 62 |
+
from src.stage2.text_emotion import predict as text_predict
|
| 63 |
+
|
| 64 |
+
logger.info("=" * 60)
|
| 65 |
+
logger.info("์์ด ํ
์คํธ ๊ฐ์ ๋ชจ๋ธ (DistilRoBERTa) ๊ฒ์ฆ ์์")
|
| 66 |
+
logger.info("=" * 60)
|
| 67 |
+
|
| 68 |
+
passed = 0
|
| 69 |
+
failed = 0
|
| 70 |
+
results = []
|
| 71 |
+
|
| 72 |
+
for text, expected in TEST_CASES:
|
| 73 |
+
result = text_predict(text, language="en")
|
| 74 |
+
|
| 75 |
+
# ์ถ๋ ฅ ํฌ๋งท ๊ฒ์ฆ
|
| 76 |
+
assert "emotion" in result, f"Missing 'emotion' key in result"
|
| 77 |
+
assert "confidence" in result, f"Missing 'confidence' key in result"
|
| 78 |
+
assert "scores" in result, f"Missing 'scores' key in result"
|
| 79 |
+
|
| 80 |
+
# ๋ชจ๋ ํ๋ก์ ํธ ๋ผ๋ฒจ์ด scores์ ์๋์ง ํ์ธ
|
| 81 |
+
for label in PROJECT_LABELS:
|
| 82 |
+
assert label in result["scores"], f"Missing label '{label}' in scores"
|
| 83 |
+
|
| 84 |
+
# scores ํฉ๊ณ ~1.0 ํ์ธ
|
| 85 |
+
score_sum = sum(result["scores"].values())
|
| 86 |
+
assert abs(score_sum - 1.0) < 0.01, f"Scores sum={score_sum}, expected ~1.0"
|
| 87 |
+
|
| 88 |
+
predicted = result["emotion"]
|
| 89 |
+
confidence = result["confidence"]
|
| 90 |
+
match = predicted == expected
|
| 91 |
+
|
| 92 |
+
if match:
|
| 93 |
+
passed += 1
|
| 94 |
+
status = "PASS"
|
| 95 |
+
else:
|
| 96 |
+
failed += 1
|
| 97 |
+
status = "FAIL"
|
| 98 |
+
|
| 99 |
+
results.append({
|
| 100 |
+
"text": text[:50],
|
| 101 |
+
"expected": expected,
|
| 102 |
+
"predicted": predicted,
|
| 103 |
+
"confidence": confidence,
|
| 104 |
+
"match": match,
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
logger.info(
|
| 108 |
+
f" [{status}] expected={expected:10s} predicted={predicted:10s} "
|
| 109 |
+
f"conf={confidence:.3f} | \"{text[:45]}...\""
|
| 110 |
+
if len(text) > 45 else
|
| 111 |
+
f" [{status}] expected={expected:10s} predicted={predicted:10s} "
|
| 112 |
+
f"conf={confidence:.3f} | \"{text}\""
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
total = passed + failed
|
| 116 |
+
accuracy = passed / total if total > 0 else 0
|
| 117 |
+
|
| 118 |
+
logger.info(f"\n{'='*60}")
|
| 119 |
+
logger.info(f"๊ฒฐ๊ณผ: {passed}/{total} PASS ({accuracy:.1%})")
|
| 120 |
+
logger.info(f" Passed: {passed}")
|
| 121 |
+
logger.info(f" Failed: {failed}")
|
| 122 |
+
|
| 123 |
+
if accuracy >= 0.8:
|
| 124 |
+
logger.info("ํ์ : PASS โ ๋ชจ๋ธ์ด ์ ์์ ์ผ๋ก ๋์ํฉ๋๋ค.")
|
| 125 |
+
elif accuracy >= 0.6:
|
| 126 |
+
logger.info("ํ์ : WARN โ ์ผ๋ถ ๊ฐ์ ์์ ๋ถ์ ํํฉ๋๋ค.")
|
| 127 |
+
else:
|
| 128 |
+
logger.info("ํ์ : FAIL โ ๋ชจ๋ธ์ ๋ฌธ์ ๊ฐ ์์ ์ ์์ต๋๋ค.")
|
| 129 |
+
|
| 130 |
+
logger.info(f"{'='*60}")
|
| 131 |
+
|
| 132 |
+
return accuracy >= 0.6
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
success = validate()
|
| 137 |
+
sys.exit(0 if success else 1)
|