{ "cells": [ { "cell_type": "markdown", "id": "d85dcf89", "metadata": {}, "source": [ "# VMC2026 Track 2 — exp04 (FUSION multi-task) — Kaggle\n", "\n", "**Mục tiêu:** gộp 2 backbone bổ sung nhau (**emotion2vec** thắng EMOS · **SAILER/WavLM** thắng VAD)\n", "thành **1 model multi-task** dự đoán chung 5 đầu ra cảm xúc: **EMOS · CAT · VAL · ARO · DOM**.\n", "QMOS để **riêng** (giữ SpeechMOS) — đúng thiết kế đã chốt: *\"QMOS riêng + 5 cảm xúc chung\"*.\n", "\n", "## Ý tưởng (đọc 1 lần cho hiểu)\n", "Bằng chứng để fusion (từ exp01 & exp03): emotion2vec đứng đầu **EMOS** (0.637), SAILER đứng đầu\n", "**VAD** (ARO 0.712 / DOM 0.630). Hai model \"nhìn\" cảm xúc theo cách khác nhau → **nối đặc trưng**\n", "của cả hai rồi cho một mạng nhỏ học → kỳ vọng mạnh hơn từng model lẻ.\n", "\n", "```\n", " ┌─ emotion2vec ─► embedding ~D1 + xác suất 5 lớp ─┐\n", " mỗi wav ──────►│ ├─► NỐI ─► TRUNK chung\n", " └─ SAILER(WavLM) ► embedding ~D2 + 9 lớp + VAD3 ─┘ (Linear+ReLU)\n", " │\n", " ┌───────────────────────────────────────────────┤\n", " target emotion(one-hot)│ │\n", " ▼ ▼\n", " [EMOS head] [CAT head] [VAD head]\n", " (cần target) (5 lớp) (VAL/ARO/DOM)\n", "```\n", "\n", "- **Cả 2 backbone ĐÓNG BĂNG** → chỉ trích đặc trưng (cache `.npz`), **chỉ train phần trunk + head nhỏ**\n", " → nhẹ GPU, train vài phút, hợp T4. (Né fine-tune end-to-end lúc đầu.)\n", "- **EMOS phụ thuộc target** (cùng audio, target khác → điểm khác) → EMOS head nhận thêm one-hot target.\n", " **CAT/VAD** là cảm nhận về chính audio → chỉ cần trunk (không cần target).\n", "- **Nhãn vàng** gộp theo `wavID` từ `sets/train.csv`:\n", " EMOS = TB `eMOS` · VAL/ARO/DOM = TB `val/aro/dom` · CAT = **tỉ lệ vote 5 lớp** của `emoCat`.\n", "- **Cân loss = uncertainty weighting** (Kendall 2018): mỗi task có 1 trọng số σ **tự học**\n", " → không phải dò tay. Có cờ `USE_UNCERTAINTY=False` để quay về trọng số cố định khi cần debug.\n", "- Cuối cùng xuất `answer.txt` **đủ 7 cột**: `wav,QMOS,EMOS,CAT,VAL,ARO,DOM`\n", " (QMOS=SpeechMOS · 5 cột còn lại = model fusion) → nộp được ngay. So mốc: EMOS 0.637 · VAD ARO 0.712.\n", "\n", "**Cách chạy trên Kaggle:** Settings → Accelerator = **GPU T4**, Internet = **On**\n", "→ + Add Input dataset Track 2 (15.477 wav, có `sets/train.csv`, `sets/dev.scp`, `metadata.csv`)\n", "→ sửa `DATA_ROOT` ở cell 0 → Run All. Lần đầu nên đặt `LIMIT_TRAIN = 300`, `LIMIT_DEV = 20` để bắt lỗi setup." ] }, { "cell_type": "markdown", "id": "5101bb4e", "metadata": {}, "source": [ "## 0. Cấu hình — SỬA Ở ĐÂY" ] }, { "cell_type": "code", "execution_count": null, "id": "3fee9b16", "metadata": {}, "outputs": [], "source": [ "import os\n", "\n", "# ── Data Track 2 (dataset 15.477 wav đã ráp, có sets/train.csv) ──────────────\n", "DATA_ROOT = \"/kaggle/input/vmc2026-track2-full/vmc2026-track2\" # << SỬA slug cho khớp Add Input\n", "WAV_DIR = f\"{DATA_ROOT}/wav\"\n", "METADATA_CSV = f\"{DATA_ROOT}/metadata.csv\" # wavID|emotion|transcript (KHÔNG header) → target emotion\n", "TRAIN_CSV = f\"{DATA_ROOT}/sets/train.csv\" # nhãn người nghe: lisID,wavID,qMOS,emoCat,eMOS,val,dom,aro\n", "DEV_SCP = f\"{DATA_ROOT}/sets/dev.scp\" # danh sách wav tập DEV (tập cần nộp ở training phase)\n", "\n", "OUT_DIR = \"/kaggle/working\"\n", "CACHE_DIR = \"/kaggle/working/fusion_cache\" # cache embedding 2 backbone (tái dùng giữa các lần chạy)\n", "os.makedirs(CACHE_DIR, exist_ok=True)\n", "\n", "# ── Siêu tham số train ───────────────────────────────────────────────────────\n", "DEVICE = \"cuda\" # \"cuda\" trên Kaggle GPU; \"cpu\" nếu không có GPU\n", "TRUNK_HIDDEN = 512 # số neuron lớp trunk chung\n", "HEAD_HIDDEN = 128 # số neuron lớp ẩn mỗi head\n", "DROPOUT = 0.3\n", "LR = 1e-3\n", "EPOCHS = 80\n", "BATCH = 64\n", "VAL_FRAC = 0.10 # 10% train → validation nội bộ (đo SRCC từng task)\n", "PATIENCE = 15 # early stop theo điểm tổng val (xem SCORE_FOR_STOP)\n", "SEED = 42\n", "\n", "USE_UNCERTAINTY = True # True = tự cân loss (Kendall); False = dùng LOSS_W cố định bên dưới\n", "LOSS_W = {\"emos\": 1.0, \"cat\": 1.0, \"val\": 1.0, \"aro\": 1.0, \"dom\": 1.0} # chỉ dùng khi tắt uncertainty\n", "USE_E2V = True # bật/tắt nhánh emotion2vec trong fusion (để ablation)\n", "USE_SAILER = True # bật/tắt nhánh SAILER trong fusion (để ablation)\n", "USE_CLASSPROB = True # thêm xác suất lớp (e2v 5 + sailer 9) + VAD3 của SAILER vào feature\n", "\n", "LIMIT_TRAIN = None # đặt số nhỏ (vd 300) để chạy thử nhanh; None = full\n", "LIMIT_DEV = None # đặt số nhỏ (vd 20) để chạy thử nhanh; None = full\n", "\n", "EMOTIONS5 = [\"angry\", \"happy\", \"neutral\", \"sad\", \"surprised\"]\n", "\n", "# 9 lớp SAILER (đúng thứ tự model xuất) + chỉ số của 5 lớp challenge trong đó\n", "SAILER9 = [\"Anger\", \"Contempt\", \"Disgust\", \"Fear\", \"Happiness\", \"Neutral\", \"Sadness\", \"Surprise\", \"Other\"]\n", "EMO2SAILER = {\"angry\": 0, \"happy\": 4, \"neutral\": 5, \"sad\": 6, \"surprised\": 7} # EMOTIONS5 → index trong SAILER9\n", "\n", "_EMO_ALIAS = {\n", " \"angry\": \"angry\", \"anger\": \"angry\",\n", " \"happy\": \"happy\", \"happiness\": \"happy\", \"joy\": \"happy\",\n", " \"neutral\": \"neutral\", \"calm\": \"neutral\",\n", " \"sad\": \"sad\", \"sadness\": \"sad\",\n", " \"surprise\": \"surprised\", \"surprised\": \"surprised\", \"surprising\": \"surprised\",\n", "}\n", "\n", "def norm_emotion(label):\n", " \"\"\"Đưa nhãn cảm xúc bất kỳ về 1 trong EMOTIONS5; None nếu không khớp.\"\"\"\n", " key = str(label).strip().lower()\n", " return _EMO_ALIAS.get(key, key if key in EMOTIONS5 else None)\n", "\n", "def stem(path_or_name):\n", " \"\"\"Lấy tên file không đuôi, để khớp wavID giữa train.csv / metadata / dev.scp.\"\"\"\n", " return os.path.splitext(os.path.basename(str(path_or_name)))[0]\n", "\n", "assert USE_E2V or USE_SAILER, \"Phải bật ít nhất 1 backbone (USE_E2V hoặc USE_SAILER).\"\n", "print(\"DATA_ROOT:\", DATA_ROOT)\n", "for p in [WAV_DIR, METADATA_CSV, TRAIN_CSV, DEV_SCP]:\n", " print((\" ✅ \" if os.path.exists(p) else \" ❌ THIẾU \") + p)" ] }, { "cell_type": "markdown", "id": "580854fb", "metadata": {}, "source": [ "## 1. Cài đặt + tải code SAILER\n", "emotion2vec qua `funasr` (offline). SAILER cần `WavLMWrapper` trong repo `vox-profile-release`\n", "→ **clone + sys.path** (KHÔNG `pip install -e .` vì build wheel hay lỗi trên Kaggle)." ] }, { "cell_type": "code", "execution_count": null, "id": "a0ea1faa", "metadata": {}, "outputs": [], "source": [ "import sys, subprocess\n", "\n", "def pip_install(*pkgs):\n", " subprocess.run([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", *pkgs], check=True)\n", "\n", "pip_install(\"speechmos\", \"funasr\", \"librosa\", \"soundfile\", \"pandas\", \"scipy\", \"scikit-learn\", \"tqdm\")\n", "\n", "if USE_SAILER:\n", " pip_install(\"loralib\", \"speechbrain\") # deps WavLMWrapper cần\n", " REPO_DIR = \"/kaggle/working/vox-profile-release\"\n", " if not os.path.exists(REPO_DIR):\n", " subprocess.run([\"git\", \"clone\", \"--depth\", \"1\",\n", " \"https://github.com/tiantiaf0627/vox-profile-release.git\", REPO_DIR], check=True)\n", " if REPO_DIR not in sys.path:\n", " sys.path.insert(0, REPO_DIR)" ] }, { "cell_type": "markdown", "id": "43033a70", "metadata": {}, "source": [ "## 2. Đọc & gộp nhãn (gộp theo wavID)\n", "- `train.csv`: mỗi dòng = 1 listener chấm 1 wav → gộp **theo wavID**:\n", " EMOS=TB `eMOS` · VAL/ARO/DOM=TB `val/aro/dom` · CAT=**tỉ lệ vote 5 lớp** của `emoCat`.\n", "- `metadata.csv`: lấy **cảm xúc target** cho mỗi wav (để feed EMOS head)." ] }, { "cell_type": "code", "execution_count": null, "id": "d4051547", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "def load_target_emotions():\n", " \"\"\"metadata.csv (wavID|emotion|transcript, KHÔNG header) → {stem: emotion_chuẩn|None}.\"\"\"\n", " tgt = {}\n", " with open(METADATA_CSV, encoding=\"utf-8\") as f:\n", " for ln in f:\n", " parts = ln.strip().split(\"|\")\n", " if len(parts) < 2:\n", " continue\n", " tgt[stem(parts[0])] = norm_emotion(parts[1])\n", " return tgt\n", "\n", "def _col(cols_map, *names, default_idx=None, df=None):\n", " for n in names:\n", " if n in cols_map:\n", " return cols_map[n]\n", " return list(df.columns)[default_idx] if default_idx is not None else None\n", "\n", "def parse_emocat_votes(cell):\n", " \"\"\"1 ô emoCat (có thể đa nhãn, vd 'happy;surprised') → vector đếm 5 lớp (chưa chuẩn hóa).\"\"\"\n", " v = np.zeros(len(EMOTIONS5), dtype=np.float32)\n", " for tok in str(cell).replace(\"/\", \",\").replace(\";\", \",\").replace(\"|\", \",\").replace(\" \", \",\").split(\",\"):\n", " e = norm_emotion(tok)\n", " if e in EMOTIONS5:\n", " v[EMOTIONS5.index(e)] += 1.0\n", " return v\n", "\n", "def load_train_labels():\n", " \"\"\"train.csv → DataFrame [wavID, emos, val, aro, dom, cat0..cat4] gộp theo wav.\n", " CAT = tỉ lệ vote 5 lớp (tổng=1); nếu wav không có vote hợp lệ → phân phối đều.\"\"\"\n", " # train.csv phân tách bằng \"|\"; cột emoCat đa nhãn dùng \",\" bên trong (vd \"Angry,Surprised\").\n", " df = pd.read_csv(TRAIN_CSV, sep=\"|\")\n", " cols = {c.lower().strip(): c for c in df.columns}\n", " wav_col = _col(cols, \"wavid\", \"wav\", default_idx=1, df=df)\n", " emos_col = _col(cols, \"emos\", \"emo\", \"emomos\")\n", " val_col = _col(cols, \"val\", \"valence\")\n", " aro_col = _col(cols, \"aro\", \"arousal\")\n", " dom_col = _col(cols, \"dom\", \"dominance\")\n", " cat_col = _col(cols, \"emocat\", \"cat\", \"emotion\")\n", " assert emos_col, f\"Không thấy cột eMOS trong train.csv (cột: {list(df.columns)})\"\n", "\n", " df[\"_stem\"] = df[wav_col].map(stem)\n", " rows = []\n", " for sid, g in df.groupby(\"_stem\"):\n", " rec = {\"wavID\": sid, \"emos\": float(g[emos_col].mean())}\n", " rec[\"val\"] = float(g[val_col].mean()) if val_col else np.nan\n", " rec[\"aro\"] = float(g[aro_col].mean()) if aro_col else np.nan\n", " rec[\"dom\"] = float(g[dom_col].mean()) if dom_col else np.nan\n", " votes = np.zeros(len(EMOTIONS5), dtype=np.float32)\n", " if cat_col:\n", " for cell in g[cat_col]:\n", " votes += parse_emocat_votes(cell)\n", " s = votes.sum()\n", " cat = votes / s if s > 0 else np.full(len(EMOTIONS5), 1.0 / len(EMOTIONS5), dtype=np.float32)\n", " for i in range(len(EMOTIONS5)):\n", " rec[f\"cat{i}\"] = float(cat[i])\n", " rows.append(rec)\n", " return pd.DataFrame(rows)\n", "\n", "target_map = load_target_emotions()\n", "train_df = load_train_labels()\n", "HAS_VAD = bool(train_df[\"val\"].notna().any())\n", "print(f\"Target emotions: {len(target_map)} | wav train (gộp): {len(train_df)} | có nhãn VAD: {HAS_VAD}\")\n", "print(\"eMOS:\", train_df[\"emos\"].describe()[[\"mean\", \"std\", \"min\", \"max\"]].to_dict())\n", "train_df.head()" ] }, { "cell_type": "markdown", "id": "6c5e27b9", "metadata": {}, "source": [ "## 3. Trích đặc trưng 2 backbone (có cache riêng từng model)\n", "- **emotion2vec** → embedding + xác suất 5 lớp (như exp02).\n", "- **SAILER** → embedding (features) + xác suất 9 lớp + VAD3 (như exp03).\n", "Mỗi backbone cache riêng (`e2v_.npz`, `sailer_.npz`) → chạy nối tiếp được, đổi 1 backbone\n", "không phải trích lại cái kia. Trích xong **giải phóng GPU** rồi mới nạp backbone sau." ] }, { "cell_type": "code", "execution_count": null, "id": "ebaac593", "metadata": { "lines_to_next_cell": 1 }, "outputs": [], "source": [ "import torch\n", "import torch.nn.functional as F\n", "\n", "device = DEVICE if torch.cuda.is_available() else \"cpu\"\n", "print(\"Device:\", device)\n", "if device == \"cuda\":\n", " print(\" ✅ GPU:\", torch.cuda.get_device_name(0))\n", "else:\n", " print(\" ⚠️ KHÔNG thấy GPU! Trích đặc trưng ~15k file trên CPU rất lâu.\")\n", " print(\" → Settings → Accelerator = GPU T4 rồi chạy lại.\")\n", "\n", "# ---- emotion2vec ----\n", "def extract_e2v(stems, tag):\n", " \"\"\"→ dict {stem: (emb[D1], probs5[5])}. Cache CACHE_DIR/e2v_.npz.\"\"\"\n", " from tqdm.auto import tqdm\n", " cache_path = os.path.join(CACHE_DIR, f\"e2v_{tag}.npz\")\n", " store = {}\n", " if os.path.exists(cache_path):\n", " z = np.load(cache_path, allow_pickle=True)\n", " store = {k: z[k] for k in z.files}\n", " print(f\"[e2v/{tag}] nạp cache: {len(store)}\")\n", " todo = [s for s in stems if s not in store]\n", " if todo:\n", " import logging\n", " logging.getLogger(\"funasr\").setLevel(logging.ERROR) # bớt log ồn của funasr\n", " from funasr import AutoModel\n", " m = AutoModel(model=\"iic/emotion2vec_plus_large\", hub=\"hf\", device=device,\n", " disable_update=True, disable_pbar=True, disable_log=True) # ép GPU + tắt log\n", " miss = 0\n", " for i, s in enumerate(tqdm(todo, desc=f\"e2v {tag}\")):\n", " wav = os.path.join(WAV_DIR, s + \".wav\")\n", " if not os.path.exists(wav):\n", " miss += 1; continue\n", " r = m.generate(wav, granularity=\"utterance\", extract_embedding=True)[0]\n", " emb = np.asarray(r[\"feats\"], dtype=np.float32).reshape(-1)\n", " probs = {e: 0.0 for e in EMOTIONS5}\n", " for lab, sc in zip(r[\"labels\"], r[\"scores\"]):\n", " name = lab.split(\"/\")[-1]\n", " if name in probs:\n", " probs[name] = float(sc)\n", " tot = sum(probs.values())\n", " p5 = np.array([probs[e] / tot if tot > 0 else 0.2 for e in EMOTIONS5], dtype=np.float32)\n", " store[s] = np.concatenate([emb, p5]).astype(np.float32) # [D1 + 5]\n", " if (i + 1) % 500 == 0:\n", " np.savez(cache_path, **store)\n", " np.savez(cache_path, **store)\n", " del m\n", " torch.cuda.empty_cache() if device == \"cuda\" else None\n", " if miss:\n", " print(f\"[e2v/{tag}] {miss} file thiếu → bỏ qua.\")\n", " return {s: (v[:-5], v[-5:]) for s, v in store.items()}\n", "\n", "# ---- SAILER ----\n", "def _pool_feat(features):\n", " \"\"\"features (tensor) → vector 1 chiều (mean-pool nếu còn chiều thời gian).\"\"\"\n", " f = features.detach().cpu().numpy()\n", " if f.ndim <= 1:\n", " return f.reshape(-1).astype(np.float32)\n", " return f.mean(axis=tuple(range(f.ndim - 1))).reshape(-1).astype(np.float32)\n", "\n", "def extract_sailer(stems, tag):\n", " \"\"\"→ dict {stem: (emb[D2], probs9[9], vad3[3] thang 1–5)}. Cache CACHE_DIR/sailer_.npz.\n", " Mỗi mẫu lưu vector [emb | probs9(9) | vad3(3)] → cắt lại khi nạp.\"\"\"\n", " import librosa\n", " from tqdm.auto import tqdm\n", " cache_path = os.path.join(CACHE_DIR, f\"sailer_{tag}.npz\")\n", " store = {}\n", " if os.path.exists(cache_path):\n", " z = np.load(cache_path, allow_pickle=True)\n", " store = {k: z[k] for k in z.files}\n", " print(f\"[sailer/{tag}] nạp cache: {len(store)}\")\n", " todo = [s for s in stems if s not in store]\n", " if todo:\n", " from src.model.emotion.wavlm_emotion import WavLMWrapper\n", " sailer = WavLMWrapper.from_pretrained(\"tiantiaf/wavlm-large-categorical-emotion\").to(device).eval()\n", " miss = 0\n", " with torch.no_grad():\n", " for i, s in enumerate(tqdm(todo, desc=f\"sailer {tag}\")):\n", " wav = os.path.join(WAV_DIR, s + \".wav\")\n", " if not os.path.exists(wav):\n", " miss += 1; continue\n", " wave, _ = librosa.load(wav, sr=16000, mono=True)\n", " wave = wave[: 15 * 16000]\n", " data = torch.from_numpy(wave).float().unsqueeze(0).to(device)\n", " logits, feat, _det, arousal, valence, dominance = sailer(data, return_feature=True)\n", " emb = _pool_feat(feat)\n", " p9 = F.softmax(logits, dim=1)[0].detach().cpu().numpy().astype(np.float32)\n", " vad3 = np.array([1 + 4 * float(valence.item()),\n", " 1 + 4 * float(arousal.item()),\n", " 1 + 4 * float(dominance.item())], dtype=np.float32) # [VAL,ARO,DOM]\n", " store[s] = np.concatenate([emb, p9, vad3]).astype(np.float32) # [D2 + 9 + 3]\n", " if (i + 1) % 500 == 0:\n", " np.savez(cache_path, **store)\n", " np.savez(cache_path, **store)\n", " del sailer\n", " torch.cuda.empty_cache() if device == \"cuda\" else None\n", " if miss:\n", " print(f\"[sailer/{tag}] {miss} file thiếu → bỏ qua.\")\n", " return {s: (v[:-12], v[-12:-3], v[-3:]) for s, v in store.items()}" ] }, { "cell_type": "markdown", "id": "751d646c", "metadata": {}, "source": [ "## 4. Dựng feature + nhãn cho train\n", "Feature audio (KHÔNG gồm target) = nối các phần đang bật:\n", "`[e2v_emb | e2v_probs5 | sailer_emb | sailer_probs9 | sailer_vad3]`.\n", "One-hot target để **riêng** (chỉ EMOS head dùng). Bỏ wav thiếu feature." ] }, { "cell_type": "code", "execution_count": null, "id": "005cdf2f", "metadata": {}, "outputs": [], "source": [ "train_stems = list(train_df[\"wavID\"])\n", "if LIMIT_TRAIN:\n", " train_stems = train_stems[:LIMIT_TRAIN]\n", "\n", "e2v_tr = extract_e2v(train_stems, \"train\") if USE_E2V else {}\n", "sailer_tr = extract_sailer(train_stems, \"train\") if USE_SAILER else {}\n", "\n", "def audio_feature(sid, e2v_map, sailer_map):\n", " \"\"\"Nối đặc trưng audio cho 1 wav. None nếu thiếu phần bắt buộc.\"\"\"\n", " parts = []\n", " if USE_E2V:\n", " pk = e2v_map.get(sid)\n", " if pk is None:\n", " return None\n", " emb, p5 = pk\n", " parts.append(emb)\n", " if USE_CLASSPROB:\n", " parts.append(p5)\n", " if USE_SAILER:\n", " pk = sailer_map.get(sid)\n", " if pk is None:\n", " return None\n", " emb, p9, vad3 = pk\n", " parts.append(emb)\n", " if USE_CLASSPROB:\n", " parts.append(p9); parts.append(vad3)\n", " return np.concatenate(parts).astype(np.float32)\n", "\n", "def onehot_target(tgt):\n", " v = np.zeros(len(EMOTIONS5), dtype=np.float32)\n", " if tgt in EMOTIONS5:\n", " v[EMOTIONS5.index(tgt)] = 1.0\n", " return v\n", "\n", "lab = train_df.set_index(\"wavID\")\n", "X, T, y_emos, y_vad, y_cat = [], [], [], [], []\n", "for s in train_stems:\n", " f = audio_feature(s, e2v_tr, sailer_tr)\n", " tgt = target_map.get(s)\n", " if f is None or tgt is None or s not in lab.index:\n", " continue\n", " X.append(f)\n", " T.append(onehot_target(tgt))\n", " y_emos.append(lab.loc[s, \"emos\"])\n", " y_vad.append([lab.loc[s, \"val\"], lab.loc[s, \"aro\"], lab.loc[s, \"dom\"]])\n", " y_cat.append([lab.loc[s, f\"cat{i}\"] for i in range(len(EMOTIONS5))])\n", "\n", "X = np.stack(X).astype(np.float32)\n", "T = np.stack(T).astype(np.float32)\n", "y_emos = np.array(y_emos, dtype=np.float32)\n", "y_vad = np.array(y_vad, dtype=np.float32) # [N,3] (VAL,ARO,DOM) — có thể toàn NaN nếu thiếu nhãn\n", "y_cat = np.array(y_cat, dtype=np.float32) # [N,5] phân phối tổng=1\n", "FEAT_DIM = X.shape[1]\n", "print(f\"Train: X={X.shape} target={T.shape} emos={y_emos.shape} vad={y_vad.shape} cat={y_cat.shape}\")\n", "\n", "# Chuẩn hóa feature audio (z-score) — lưu mean/std để áp dụng y hệt lúc dự đoán DEV.\n", "feat_mean = X.mean(0, keepdims=True)\n", "feat_std = X.std(0, keepdims=True) + 1e-6\n", "Xn = (X - feat_mean) / feat_std\n", "\n", "# Chuẩn hóa nhãn liên tục (eMOS, VAD) về z-score → các MSE cùng thang (uncertainty weighting ổn định hơn).\n", "# SRCC bất biến với scale → khi xuất answer.txt chỉ cần đảo z-score về thang gốc cho đẹp.\n", "emos_mu, emos_sd = float(y_emos.mean()), float(y_emos.std() + 1e-6)\n", "y_emos_z = (y_emos - emos_mu) / emos_sd\n", "if HAS_VAD:\n", " vad_mu = np.nanmean(y_vad, axis=0)\n", " vad_sd = np.nanstd(y_vad, axis=0) + 1e-6\n", " y_vad_z = (y_vad - vad_mu) / vad_sd\n", "else:\n", " vad_mu = np.zeros(3, dtype=np.float32); vad_sd = np.ones(3, dtype=np.float32)\n", " y_vad_z = np.zeros_like(y_vad)" ] }, { "cell_type": "markdown", "id": "f41faa42", "metadata": {}, "source": [ "## 5. Model fusion multi-task + train loop\n", "- **Trunk** chung: `Linear(FEAT_DIM→TRUNK_HIDDEN)+ReLU+Dropout` (×2).\n", "- **EMOS head**: nối `[trunk | one-hot target]` → MLP → 1 (vì EMOS phụ thuộc target).\n", "- **CAT head**: trunk → 5 logits → softmax (dự đoán phân phối vote). Loss = soft-CE (KL).\n", "- **VAD head**: trunk → 3 (VAL/ARO/DOM). Loss = MSE (bỏ qua nếu thiếu nhãn VAD).\n", "- **Cân loss**: uncertainty weighting — tổng `Σ exp(-sᵢ)·Lᵢ + sᵢ`, `sᵢ=log σᵢ²` **học được**." ] }, { "cell_type": "code", "execution_count": null, "id": "dc5e0242", "metadata": { "lines_to_next_cell": 1 }, "outputs": [], "source": [ "import torch.nn as nn\n", "from scipy.stats import spearmanr\n", "from sklearn.model_selection import train_test_split\n", "\n", "torch.manual_seed(SEED); np.random.seed(SEED)\n", "N_EMO = len(EMOTIONS5)\n", "\n", "idx_all = np.arange(X.shape[0])\n", "tr_idx, va_idx = train_test_split(idx_all, test_size=VAL_FRAC, random_state=SEED)\n", "\n", "def to_t(a):\n", " return torch.tensor(a, dtype=torch.float32, device=device)\n", "\n", "Xn_t, T_t = to_t(Xn), to_t(T)\n", "emos_t = to_t(y_emos_z).unsqueeze(1)\n", "vad_t = to_t(y_vad_z)\n", "cat_t = to_t(y_cat)\n", "\n", "class FusionMTL(nn.Module):\n", " def __init__(self, d_in, trunk_h, head_h, p, n_emo):\n", " super().__init__()\n", " self.trunk = nn.Sequential(\n", " nn.Linear(d_in, trunk_h), nn.ReLU(), nn.Dropout(p),\n", " nn.Linear(trunk_h, trunk_h), nn.ReLU(), nn.Dropout(p),\n", " )\n", " self.emos = nn.Sequential( # nhận [trunk | target]\n", " nn.Linear(trunk_h + n_emo, head_h), nn.ReLU(), nn.Dropout(p), nn.Linear(head_h, 1))\n", " self.cat = nn.Sequential(\n", " nn.Linear(trunk_h, head_h), nn.ReLU(), nn.Dropout(p), nn.Linear(head_h, n_emo))\n", " self.vad = nn.Sequential(\n", " nn.Linear(trunk_h, head_h), nn.ReLU(), nn.Dropout(p), nn.Linear(head_h, 3))\n", "\n", " def forward(self, x, tgt):\n", " h = self.trunk(x)\n", " emos = self.emos(torch.cat([h, tgt], dim=1))\n", " cat_logits = self.cat(h)\n", " vad = self.vad(h)\n", " return emos, cat_logits, vad\n", "\n", "model = FusionMTL(FEAT_DIM, TRUNK_HIDDEN, HEAD_HIDDEN, DROPOUT, N_EMO).to(device)\n", "\n", "# Trọng số bất định (log σ²) cho 5 task: emos, cat, val, aro, dom.\n", "TASKS = [\"emos\", \"cat\", \"val\", \"aro\", \"dom\"]\n", "log_var = nn.Parameter(torch.zeros(len(TASKS), device=device))\n", "params = list(model.parameters()) + ([log_var] if USE_UNCERTAINTY else [])\n", "opt = torch.optim.Adam(params, lr=LR, weight_decay=1e-5)\n", "\n", "mse = nn.MSELoss(reduction=\"none\")\n", "\n", "def soft_ce(logits, target_dist):\n", " \"\"\"Cross-entropy với nhãn mềm (phân phối): −Σ p·log q.\"\"\"\n", " logq = F.log_softmax(logits, dim=1)\n", " return -(target_dist * logq).sum(dim=1)\n", "\n", "def task_losses(emos_p, cat_logits, vad_p, b):\n", " \"\"\"Trả về dict loss TB từng task cho 1 batch (chỉ số b).\"\"\"\n", " L = {}\n", " L[\"emos\"] = mse(emos_p, emos_t[b]).mean()\n", " L[\"cat\"] = soft_ce(cat_logits, cat_t[b]).mean()\n", " if HAS_VAD:\n", " L[\"val\"] = mse(vad_p[:, 0:1], vad_t[b, 0:1]).mean()\n", " L[\"aro\"] = mse(vad_p[:, 1:2], vad_t[b, 1:2]).mean()\n", " L[\"dom\"] = mse(vad_p[:, 2:3], vad_t[b, 2:3]).mean()\n", " else:\n", " z = torch.zeros((), device=device)\n", " L[\"val\"] = L[\"aro\"] = L[\"dom\"] = z\n", " return L\n", "\n", "def combine(L):\n", " \"\"\"Gộp 5 loss thành 1 số: uncertainty weighting hoặc trọng số cố định.\"\"\"\n", " if USE_UNCERTAINTY:\n", " tot = 0.0\n", " for i, t in enumerate(TASKS):\n", " tot = tot + torch.exp(-log_var[i]) * L[t] + log_var[i]\n", " return tot\n", " return sum(LOSS_W[t] * L[t] for t in TASKS)\n", "\n", "@torch.no_grad()\n", "def eval_val():\n", " \"\"\"SRCC từng task trên tập val nội bộ (CAT báo bằng −KL để 'cao=tốt' cho early-stop).\"\"\"\n", " model.eval()\n", " ep, cl, vp = model(Xn_t[va_idx], T_t[va_idx])\n", " ep = ep.cpu().numpy().ravel()\n", " out = {\"emos\": spearmanr(ep, y_emos[va_idx]).correlation}\n", " if HAS_VAD:\n", " vp = vp.cpu().numpy()\n", " for j, t in enumerate([\"val\", \"aro\", \"dom\"]):\n", " out[t] = spearmanr(vp[:, j], y_vad[va_idx, j]).correlation\n", " # CAT: dùng −KL(p‖q) trung bình (càng gần 0 càng tốt) → đổi dấu để hợp early-stop\n", " q = F.softmax(cl, dim=1).cpu().numpy()\n", " p = y_cat[va_idx]\n", " kl = (p * (np.log(p + 1e-9) - np.log(q + 1e-9))).sum(1).mean()\n", " out[\"cat_negkl\"] = float(-kl)\n", " return out\n", "\n", "def val_score(m):\n", " \"\"\"Điểm tổng để early-stop = TB SRCC các task liên tục có nhãn.\"\"\"\n", " keys = [\"emos\"] + ([\"val\", \"aro\", \"dom\"] if HAS_VAD else [])\n", " return float(np.mean([m[k] for k in keys]))\n", "\n", "best_score, best_state, bad = -1e9, None, 0\n", "tr_t = torch.tensor(tr_idx, device=device)\n", "for ep in range(1, EPOCHS + 1):\n", " model.train()\n", " perm = tr_t[torch.randperm(len(tr_t), device=device)]\n", " run = 0.0\n", " for i in range(0, len(perm), BATCH):\n", " b = perm[i:i + BATCH]\n", " opt.zero_grad()\n", " emos_p, cat_logits, vad_p = model(Xn_t[b], T_t[b])\n", " L = task_losses(emos_p, cat_logits, vad_p, b)\n", " loss = combine(L)\n", " loss.backward(); opt.step()\n", " run += loss.item() * len(b)\n", " m = eval_val()\n", " sc = val_score(m)\n", " if sc > best_score:\n", " best_score = sc\n", " best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}\n", " bad = 0\n", " else:\n", " bad += 1\n", " if ep % 5 == 0 or ep == 1:\n", " msg = \" \".join(f\"{k}={m[k]:.3f}\" for k in m)\n", " print(f\"epoch {ep:3d} | loss {run/len(perm):.4f} | {msg} | best {best_score:.4f}\")\n", " if bad >= PATIENCE:\n", " print(f\"Early stop ở epoch {ep}.\")\n", " break\n", "\n", "model.load_state_dict(best_state)\n", "final = eval_val()\n", "print(\"\\n✅ VAL (nội bộ) tốt nhất:\")\n", "print(f\" EMOS SRCC = {final['emos']:.4f} (so mốc exp01 emotion2vec = 0.637)\")\n", "if HAS_VAD:\n", " print(f\" VAL/ARO/DOM SRCC = {final['val']:.4f} / {final['aro']:.4f} / {final['dom']:.4f}\"\n", " f\" (so mốc SAILER = 0.341 / 0.712 / 0.630)\")\n", "if USE_UNCERTAINTY:\n", " print(\" log σ² mỗi task:\", {t: round(float(log_var[i]), 3) for i, t in enumerate(TASKS)})\n", "\n", "# Lưu model + tham số chuẩn hóa.\n", "torch.save({\"state\": best_state, \"feat_mean\": feat_mean, \"feat_std\": feat_std,\n", " \"emos_mu\": emos_mu, \"emos_sd\": emos_sd, \"vad_mu\": vad_mu, \"vad_sd\": vad_sd,\n", " \"FEAT_DIM\": FEAT_DIM, \"EMOTIONS5\": EMOTIONS5, \"HAS_VAD\": HAS_VAD,\n", " \"USE_E2V\": USE_E2V, \"USE_SAILER\": USE_SAILER, \"USE_CLASSPROB\": USE_CLASSPROB,\n", " \"TRUNK_HIDDEN\": TRUNK_HIDDEN, \"HEAD_HIDDEN\": HEAD_HIDDEN, \"val_score\": best_score},\n", " os.path.join(OUT_DIR, \"fusion_mtl.pt\"))\n", "print(\"Đã lưu\", os.path.join(OUT_DIR, \"fusion_mtl.pt\"))" ] }, { "cell_type": "markdown", "id": "39e3c014", "metadata": {}, "source": [ "## 6. Dự đoán DEV → `answer.txt` đầy đủ 7 cột\n", "- **EMOS/CAT/VAD** = model fusion (đảo z-score về thang gốc cho EMOS/VAD; CAT = softmax 5 lớp).\n", "- **QMOS** = SpeechMOS (UTMOS) — để riêng, đúng thiết kế." ] }, { "cell_type": "code", "execution_count": null, "id": "c9d06ec4", "metadata": { "lines_to_next_cell": 1 }, "outputs": [], "source": [ "def list_dev():\n", " with open(DEV_SCP) as f:\n", " return [ln.strip() for ln in f if ln.strip()] # tên file .wav\n", "\n", "dev_names = list_dev()\n", "if LIMIT_DEV:\n", " dev_names = dev_names[:LIMIT_DEV]\n", "dev_stems = [stem(n) for n in dev_names]\n", "print(\"DEV:\", len(dev_names), \"mẫu\")\n", "\n", "# 6a. Trích đặc trưng 2 backbone cho DEV (cache riêng)\n", "e2v_dev = extract_e2v(dev_stems, \"dev\") if USE_E2V else {}\n", "sailer_dev = extract_sailer(dev_stems, \"dev\") if USE_SAILER else {}\n", "\n", "# 6b. Dự đoán 5 cột cảm xúc bằng model fusion\n", "@torch.no_grad()\n", "def predict_emotion(sid):\n", " f = audio_feature(sid, e2v_dev, sailer_dev)\n", " if f is None:\n", " return None\n", " fn = (f[None, :] - feat_mean) / feat_std\n", " tgt = onehot_target(target_map.get(sid))[None, :]\n", " model.eval()\n", " emos_p, cat_logits, vad_p = model(to_t(fn), to_t(tgt))\n", " emos = float(emos_p.item()) * emos_sd + emos_mu # đảo z-score\n", " cat5 = F.softmax(cat_logits, dim=1)[0].cpu().numpy()\n", " vad3 = vad_p[0].cpu().numpy() * vad_sd + vad_mu # [VAL,ARO,DOM]\n", " return emos, cat5, vad3\n", "\n", "# 6c. QMOS = SpeechMOS (để riêng)\n", "@torch.no_grad()\n", "def run_qmos(names):\n", " import librosa\n", " from tqdm.auto import tqdm\n", " predictor = torch.hub.load(\"tarepan/SpeechMOS:v1.2.0\", \"utmos22_strong\", trust_repo=True).to(device).eval()\n", " out = {}\n", " for n in tqdm(names, desc=\"QMOS\"):\n", " p = os.path.join(WAV_DIR, n)\n", " if not os.path.exists(p):\n", " continue\n", " wave, _ = librosa.load(p, sr=16000, mono=True)\n", " out[n] = float(predictor(torch.from_numpy(wave).unsqueeze(0).to(device), sr=16000).mean().item())\n", " return out\n", "\n", "qmos_scores = run_qmos(dev_names)" ] }, { "cell_type": "code", "execution_count": null, "id": "999f19fc", "metadata": { "lines_to_next_cell": 1 }, "outputs": [], "source": [ "def fmt_cat(probs5):\n", " return \"|\".join(f\"{e}:{probs5[i]:.6g}\" for i, e in enumerate(EMOTIONS5))\n", "\n", "def build_answer(out_path):\n", " from tqdm.auto import tqdm\n", " n_real = n_default = 0\n", " with open(out_path, \"w\") as f:\n", " f.write(\"wav,QMOS,EMOS,CAT,VAL,ARO,DOM\\n\")\n", " for name in tqdm(dev_names, desc=\"answer\"):\n", " sid = stem(name)\n", " pred = predict_emotion(sid)\n", " if pred is None:\n", " emos, cat5, vad3 = 3.0, np.full(5, 0.2, np.float32), np.array([3.0, 3.0, 3.0])\n", " n_default += 1\n", " else:\n", " emos, cat5, vad3 = pred\n", " n_real += 1\n", " qmos = qmos_scores.get(name, 3.0)\n", " f.write(f\"{name},{qmos:.6g},{emos:.6g},{fmt_cat(cat5)},\"\n", " f\"{vad3[0]:.6g},{vad3[1]:.6g},{vad3[2]:.6g}\\n\")\n", " print(f\"Ghi {len(dev_names)} dòng → {out_path} | fusion thật {n_real}, mặc định {n_default}\")\n", "\n", "answer_path = os.path.join(OUT_DIR, \"answer.txt\")\n", "build_answer(answer_path)" ] }, { "cell_type": "markdown", "id": "708acd7a", "metadata": {}, "source": [ "## 7. Validate + đóng zip" ] }, { "cell_type": "code", "execution_count": null, "id": "ba406750", "metadata": {}, "outputs": [], "source": [ "def validate(path):\n", " import csv\n", " with open(path) as f:\n", " rows = list(csv.reader(f))\n", " header = rows[0]\n", " assert header[0] == \"wav\" and \"QMOS\" in header and \"EMOS\" in header, \"Header sai\"\n", " for i, r in enumerate(rows[1:], 2):\n", " assert len(r) == len(header), f\"Dòng {i} sai số cột\"\n", " print(f\"OK: {len(rows)-1} dòng, header = {header}\")\n", "\n", "validate(answer_path)\n", "os.system(f\"cd {OUT_DIR} && zip -j submission_track2_exp04_fusion.zip answer.txt && unzip -l submission_track2_exp04_fusion.zip\")\n", "print(\"Sẵn sàng nộp:\", os.path.join(OUT_DIR, \"submission_track2_exp04_fusion.zip\"))" ] }, { "cell_type": "markdown", "id": "c0f4e2ae", "metadata": {}, "source": [ "## Ghi chú\n", "- **Lần đầu**: đặt `LIMIT_TRAIN=300`, `LIMIT_DEV=20` ở cell 0 để bắt lỗi setup (clone repo / import / model).\n", " Chạy OK rồi đặt `None` chạy full.\n", "- **VAL SRCC** ở mục 5 là ước lượng nội bộ (10% train) → so mốc EMOS 0.637 / ARO 0.712. Điểm DEV thật\n", " phải nộp CodaBench mới biết (My Submissions → Track 2, bỏ chọn track khác).\n", "- Embedding đã cache trong `/kaggle/working/fusion_cache/` → **Save Version** để giữ; lần sau đổi\n", " siêu tham số/đổi cách cân loss chỉ train lại head (vài phút), khỏi trích lại.\n", "- **Ablation cho paper** (đổi cờ ở cell 0, train lại head):\n", " `USE_E2V=False` (chỉ SAILER) · `USE_SAILER=False` (chỉ emotion2vec) · `USE_UNCERTAINTY=False` (trọng số tay)\n", " · `USE_CLASSPROB=False` (chỉ embedding) → điền bảng ablation `docs/04_experiments_log.md`.\n", "- License SAILER = **Open RAIL (phi thương mại)** → nhắc trong `docs/12_system_description.md`.\n", "- Nhớ ghi config → kết quả → nhận xét vào `docs/04_experiments_log.md` (mục exp04)." ] } ], "metadata": { "jupytext": { "cell_metadata_filter": "-all", "main_language": "python", "notebook_metadata_filter": "-all" } }, "nbformat": 4, "nbformat_minor": 5 }