{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":129271,"databundleVersionId":15506307,"sourceType":"competition"}],"dockerImageVersionId":31260,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip -q install soundfile torchaudio speechbrain scikit-learn tqdm silero-vad","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:37.687932Z","iopub.execute_input":"2026-02-13T17:38:37.688697Z","iopub.status.idle":"2026-02-13T17:38:41.080218Z","shell.execute_reply.started":"2026-02-13T17:38:37.688668Z","shell.execute_reply":"2026-02-13T17:38:41.079224Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import json\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport soundfile as sf\nimport torch\nimport torchaudio\nfrom tqdm.auto import tqdm\n\nfrom sklearn.cluster import AgglomerativeClustering\nfrom sklearn.metrics import silhouette_score\nfrom sklearn.preprocessing import normalize\n\nimport matplotlib.pyplot as plt","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:41.082533Z","iopub.execute_input":"2026-02-13T17:38:41.082784Z","iopub.status.idle":"2026-02-13T17:38:41.088055Z","shell.execute_reply.started":"2026-02-13T17:38:41.082756Z","shell.execute_reply":"2026-02-13T17:38:41.087416Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip -q install pyannote.core pyannote.metrics","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:41.089015Z","iopub.execute_input":"2026-02-13T17:38:41.089273Z","iopub.status.idle":"2026-02-13T17:38:44.295480Z","shell.execute_reply.started":"2026-02-13T17:38:41.089239Z","shell.execute_reply":"2026-02-13T17:38:44.294492Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import pandas as pd\nfrom pathlib import Path\n\nfrom pyannote.core import Annotation, Segment\nfrom pyannote.metrics.diarization import DiarizationErrorRate\n\ndef _parse_hhmmss_to_seconds(t: str) -> float:\n    # Handles \"HH:MM:SS\" and \"HH:MM:SS.sss\" safely\n    t = str(t).strip()\n    parts = t.split(\":\")\n    if len(parts) != 3:\n        raise ValueError(f\"Bad time format: {t}\")\n    hh = int(parts[0])\n    mm = int(parts[1])\n    ss = float(parts[2])\n    return hh * 3600 + mm * 60 + ss\n\ndef load_gt_annotation(csv_path: str | Path) -> Annotation:\n    df = pd.read_csv(csv_path)\n\n    # normalize column names\n    colmap = {c.lower(): c for c in df.columns}\n    start_col = colmap.get(\"start_time\") or colmap.get(\"start_time\".lower()) or colmap.get(\"start_time\".replace(\"_\",\"\")) \n    end_col   = colmap.get(\"end_time\")   or colmap.get(\"end_time\".lower())   or colmap.get(\"end_time\".replace(\"_\",\"\"))\n    spk_col   = colmap.get(\"speaker_id\") or colmap.get(\"speaker_id\".lower()) or colmap.get(\"speaker_id\".replace(\"_\",\"\"))\n\n    # handle train_013.csv capitalization\n    if start_col is None:\n        # try common variants\n        for cand in [\"Start_time\", \"Start_Time\", \"start\", \"Start\"]:\n            if cand in df.columns: start_col = cand; break\n    if end_col is None:\n        for cand in [\"End_time\", \"End_Time\", \"end\", \"End\"]:\n            if cand in df.columns: end_col = cand; break\n    if spk_col is None:\n        for cand in [\"Speaker_ID\", \"speaker\", \"Speaker\", \"spk\", \"Spk\"]:\n            if cand in df.columns: spk_col = cand; break\n\n    if start_col is None or end_col is None or spk_col is None:\n        raise ValueError(f\"Could not detect columns in {csv_path}. Found: {df.columns.tolist()}\")\n\n    ann = Annotation()\n    for _, r in df.iterrows():\n        s = _parse_hhmmss_to_seconds(r[start_col])\n        e = _parse_hhmmss_to_seconds(r[end_col])\n        spk = str(r[spk_col])\n        if e <= s:\n            continue\n        ann[Segment(s, e)] = spk\n    return ann\n\ndef pred_to_annotation(pred_list: list[dict]) -> Annotation:\n    \"\"\"\n    pred_list: your diarize_file() output:\n      [{\"start_time\": 0.5, \"end_time\": 3.2, \"speaker_id\": \"SPEAKER_1\"}, ...]\n    \"\"\"\n    ann = Annotation()\n    for r in pred_list:\n        s = float(r[\"start_time\"])\n        e = float(r[\"end_time\"])\n        spk = str(r[\"speaker_id\"])\n        if e <= s:\n            continue\n        ann[Segment(s, e)] = spk\n    return ann\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.297893Z","iopub.execute_input":"2026-02-13T17:38:44.298174Z","iopub.status.idle":"2026-02-13T17:38:44.310851Z","shell.execute_reply.started":"2026-02-13T17:38:44.298145Z","shell.execute_reply":"2026-02-13T17:38:44.310057Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# === FILE PATH ===\nAUDIO_DIR = Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/test/audio\")          \nOUT_PATH = Path(\"submission.csv\")  \n\n# === Device ===\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nDEVICE","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.311950Z","iopub.execute_input":"2026-02-13T17:38:44.312278Z","iopub.status.idle":"2026-02-13T17:38:44.335541Z","shell.execute_reply.started":"2026-02-13T17:38:44.312245Z","shell.execute_reply":"2026-02-13T17:38:44.334913Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"wav_paths = sorted(AUDIO_DIR.glob(\"*.wav\"))\nstems = [p.stem for p in wav_paths]\n\nlen(wav_paths), (wav_paths[0] if wav_paths else None)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.336499Z","iopub.execute_input":"2026-02-13T17:38:44.336744Z","iopub.status.idle":"2026-02-13T17:38:44.351425Z","shell.execute_reply.started":"2026-02-13T17:38:44.336724Z","shell.execute_reply":"2026-02-13T17:38:44.350835Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def load_audio_mono_16k(path: str | Path, target_sr: int = 16000) -> np.ndarray:\n    \"\"\"Load audio file as mono float32 at target_sr.\"\"\"\n    wav, sr = sf.read(str(path))\n    if wav.ndim == 2:\n        wav = wav.mean(axis=1)\n    wav = wav.astype(np.float32)\n\n    if sr != target_sr:\n        x = torch.from_numpy(wav).unsqueeze(0)\n        resampler = torchaudio.transforms.Resample(orig_freq=sr, new_freq=target_sr)\n        wav = resampler(x).squeeze(0).cpu().numpy().astype(np.float32)\n    return wav","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.352294Z","iopub.execute_input":"2026-02-13T17:38:44.352522Z","iopub.status.idle":"2026-02-13T17:38:44.357356Z","shell.execute_reply.started":"2026-02-13T17:38:44.352502Z","shell.execute_reply":"2026-02-13T17:38:44.356822Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\nUSE_PIP = True   # set False to use torch.hub instead\nUSE_ONNX = False # set True to use ONNX model\nVAD_DEVICE=\"cpu\" # silero VAD is light-weight,okay with cpu \n\nif USE_PIP:\n    from silero_vad import (\n        load_silero_vad,\n        get_speech_timestamps,\n        read_audio,\n        save_audio,\n        VADIterator,\n        collect_chunks,\n    )\n    silero_model = load_silero_vad(onnx=USE_ONNX)\nelse:\n    silero_model, utils = torch.hub.load(\n        repo_or_dir=\"snakers4/silero-vad\",\n        model=\"silero_vad\",\n        trust_repo=True,\n        onnx=USE_ONNX,\n    )\n    (get_speech_timestamps, save_audio, read_audio, VADIterator, collect_chunks) = utils\n\nsilero_model.to(VAD_DEVICE)\nsilero_model.eval()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.358320Z","iopub.execute_input":"2026-02-13T17:38:44.358592Z","iopub.status.idle":"2026-02-13T17:38:44.437951Z","shell.execute_reply.started":"2026-02-13T17:38:44.358566Z","shell.execute_reply":"2026-02-13T17:38:44.437364Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def silero_vad_speech_segments(\n    wav16k: np.ndarray,\n    sr: int = 16000,\n    vad_threshold: float = 0.35,\n    padding_ms: int = 200,\n    min_silence_ms: int = 300,\n    min_speech_ms: int = 200,\n):\n    \"\"\"\n    Returns list of (start_sec, end_sec) speech segments using Silero neural VAD.\n    Runs VAD on the full waveform (more robust than streaming chunk iteration).\n    \"\"\"\n    assert sr == 16000, \"Silero VAD expects 16kHz audio\"\n\n    x = torch.from_numpy(wav16k).float().to(VAD_DEVICE)  # Silero utils typically expect CPU tensor\n\n    ts = get_speech_timestamps(\n        x,\n        silero_model,\n        sampling_rate=sr,\n        threshold=vad_threshold,\n        min_speech_duration_ms=min_speech_ms,\n        min_silence_duration_ms=min_silence_ms,\n        speech_pad_ms=padding_ms,\n    )\n\n\n    segs = [(t[\"start\"] / sr, t[\"end\"] / sr) for t in ts]\n\n    # Defensive merge in case of overlaps\n    merged = []\n    for s, e in sorted(segs):\n        if not merged or s > merged[-1][1]:\n            merged.append([s, e])\n        else:\n            merged[-1][1] = max(merged[-1][1], e)\n\n    return [(float(s), float(e)) for s, e in merged]\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.438879Z","iopub.execute_input":"2026-02-13T17:38:44.439194Z","iopub.status.idle":"2026-02-13T17:38:44.445592Z","shell.execute_reply.started":"2026-02-13T17:38:44.439171Z","shell.execute_reply":"2026-02-13T17:38:44.444876Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def plot_waveform_with_segments(wav, sr, segments, title=\"Waveform + VAD\"):\n    t = np.arange(len(wav)) / sr\n    plt.figure(figsize=(12, 3))\n    plt.plot(t, wav, linewidth=0.7)\n    for s, e in segments:\n        plt.axvspan(s, e, alpha=0.25)\n    plt.xlabel(\"Time (s)\")\n    plt.title(title)\n    plt.grid(True, axis=\"x\")\n    plt.show()\n\n# Example on one file\nif wav_paths:\n    wav = load_audio_mono_16k(wav_paths[0], 16000)\n    speech_segs = silero_vad_speech_segments(wav, sr=16000)\n    plot_waveform_with_segments(wav, 16000, speech_segs, title=f\"VAD: {wav_paths[0].name}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:38:44.447827Z","iopub.execute_input":"2026-02-13T17:38:44.448065Z","iopub.status.idle":"2026-02-13T17:39:51.479453Z","shell.execute_reply.started":"2026-02-13T17:38:44.448044Z","shell.execute_reply":"2026-02-13T17:39:51.478805Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def make_windows(segments, win_sec=1.8, hop_sec=0.6):\n    \"\"\"Create sliding windows (ws,we) inside each speech segment.\"\"\"\n    windows = []\n    for s, e in segments:\n        dur = e - s\n        if dur <= win_sec:\n            windows.append((s, e))\n            continue\n        t = s\n        while t + win_sec <= e + 1e-6:\n            windows.append((t, t + win_sec))\n            t += hop_sec\n        if windows and windows[-1][1] < e:\n            windows.append((max(s, e - win_sec), e))\n\n    # de-dup (rounding is only for identity keys)\n    out, seen = [], set()\n    for ws, we in windows:\n        key = (round(ws, 3), round(we, 3))\n        if key not in seen:\n            out.append((float(ws), float(we)))\n            seen.add(key)\n    return out","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:51.480343Z","iopub.execute_input":"2026-02-13T17:39:51.480623Z","iopub.status.idle":"2026-02-13T17:39:51.486633Z","shell.execute_reply.started":"2026-02-13T17:39:51.480588Z","shell.execute_reply":"2026-02-13T17:39:51.485886Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from speechbrain.pretrained import EncoderClassifier\n\nspk_model = EncoderClassifier.from_hparams(\n    source=\"speechbrain/spkrec-ecapa-voxceleb\",\n    run_opts={\"device\": DEVICE},\n)\n\"ECAPA model loaded\" ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:51.487549Z","iopub.execute_input":"2026-02-13T17:39:51.487818Z","iopub.status.idle":"2026-02-13T17:39:52.309259Z","shell.execute_reply.started":"2026-02-13T17:39:51.487788Z","shell.execute_reply":"2026-02-13T17:39:52.308681Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nimport torch\n\n@torch.inference_mode()\ndef ecapa_embeddings_batch(\n    wavs: list[np.ndarray],\n    sr: int = 16000,\n    batch_size: int = 12,\n    max_chunk_sec: float = 3.0,\n) -> np.ndarray:\n    \"\"\"\n    Memory-safe batching for SpeechBrain ECAPA:\n    - Cap each chunk to max_chunk_sec seconds (trim)\n    - Sort by length so padding waste is small\n    - Process in micro-batches\n    \"\"\"\n    if len(wavs) == 0:\n        return np.empty((0, 192), dtype=np.float32)\n\n    max_len = int(max_chunk_sec * sr)\n\n    # Trim to max_len to avoid one long chunk exploding padding + STFT memory\n    wavs2 = [w[:max_len].astype(np.float32, copy=False) for w in wavs]\n\n    # Sort by length (reduces padding)\n    order = np.argsort([len(w) for w in wavs2])\n    wavs2 = [wavs2[i] for i in order]\n\n    outs = []\n    for i in range(0, len(wavs2), batch_size):\n        batch_wavs = wavs2[i:i+batch_size]\n        lengths = [len(w) for w in batch_wavs]\n        m = max(lengths)\n\n        batch = np.zeros((len(batch_wavs), m), dtype=np.float32)\n        for j, w in enumerate(batch_wavs):\n            batch[j, :len(w)] = w\n\n        x = torch.from_numpy(batch).to(DEVICE)\n\n        # SpeechBrain expects (B, T) in most setups\n        emb = spk_model.encode_batch(x)  # (B, 1, D) or (B, D)\n        emb = emb.squeeze().detach().cpu().numpy().astype(np.float32)\n        if emb.ndim == 1:\n            emb = emb[None, :]\n\n        outs.append(emb)\n\n        # free GPU memory\n        del x, emb\n        torch.cuda.empty_cache()\n\n    embs_sorted = np.vstack(outs) if len(outs) else np.empty((0, 192), dtype=np.float32)\n\n    # Undo sorting to match original wavs order\n    inv = np.empty_like(order)\n    inv[order] = np.arange(len(order))\n    embs = embs_sorted[inv]\n\n    return embs\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.310272Z","iopub.execute_input":"2026-02-13T17:39:52.310581Z","iopub.status.idle":"2026-02-13T17:39:52.319680Z","shell.execute_reply.started":"2026-02-13T17:39:52.310556Z","shell.execute_reply":"2026-02-13T17:39:52.318999Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def make_subchunks(chunk, sr=16000):\n    \"\"\"\n    Create 3 overlapping subchunks if long enough.\n    Returns list[np.ndarray] always non-empty.\n    \"\"\"\n    x = np.asarray(chunk)\n    n = len(x)\n    if n < sr:  # < 1s\n        return [x]\n\n    step = n // 4\n    subs = [\n        x[0:step*2],\n        x[step:step*3],\n        x[step*2:n],\n    ]\n    # keep >=0.5s\n    subs = [s for s in subs if len(s) >= int(0.5 * sr)]\n    return subs if subs else [x]\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.320531Z","iopub.execute_input":"2026-02-13T17:39:52.320806Z","iopub.status.idle":"2026-02-13T17:39:52.335297Z","shell.execute_reply.started":"2026-02-13T17:39:52.320775Z","shell.execute_reply":"2026-02-13T17:39:52.334483Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def estimate_num_speakers(*args, **kwargs):\n    \"\"\"\n    Disabled: silhouette-based speaker count estimation is unstable for long-form diarization.\n    We will use threshold-based clustering instead.\n    \"\"\"\n    raise RuntimeError(\"Do not call estimate_num_speakers(). Use threshold clustering instead.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.336236Z","iopub.execute_input":"2026-02-13T17:39:52.336540Z","iopub.status.idle":"2026-02-13T17:39:52.350751Z","shell.execute_reply.started":"2026-02-13T17:39:52.336501Z","shell.execute_reply":"2026-02-13T17:39:52.350177Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\nfrom sklearn.cluster import AgglomerativeClustering\n\ndef cluster_embeddings(\n    embs,\n    distance_threshold=0.65,\n    max_speakers=25,\n    min_speakers=1,\n):\n    \"\"\"\n    Threshold AHC (pyannote-like): n_clusters=None + distance_threshold.\n    Then optional cap to max_speakers by mapping small clusters to nearest large ones.\n    \"\"\"\n    X = np.asarray(embs, dtype=np.float32)\n    X = X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-9)\n\n    # --- true threshold clustering ---\n    ahc = AgglomerativeClustering(\n        n_clusters=None,\n        metric=\"cosine\",\n        linkage=\"average\",\n        distance_threshold=float(distance_threshold),\n    )\n    labels = ahc.fit_predict(X).astype(np.int64)\n\n    # --- enforce min speakers (rare) ---\n    n_spk = len(np.unique(labels))\n    if n_spk < min_speakers:\n        # force single cluster\n        labels[:] = 0\n        return labels\n\n    # --- optional cap: reduce to max_speakers by reassigning smallest clusters ---\n    if max_speakers is not None and n_spk > max_speakers:\n        # rank clusters by size, keep top-K\n        uniq, cnt = np.unique(labels, return_counts=True)\n        order = uniq[np.argsort(-cnt)]\n        keep = set(order[:max_speakers])\n\n        # centroids of kept clusters\n        keep_list = np.array(sorted(list(keep)), dtype=np.int64)\n        C = []\n        for c in keep_list:\n            v = X[labels == c].mean(axis=0)\n            v = v / (np.linalg.norm(v) + 1e-9)\n            C.append(v)\n        C = np.stack(C, axis=0)  # [K, D]\n\n        new_labels = labels.copy()\n        for c in uniq:\n            if c in keep:\n                continue\n            idx = np.where(labels == c)[0]\n            sims = X[idx] @ C.T\n            best = keep_list[np.argmax(sims, axis=1)]\n            new_labels[idx] = best\n\n        # remap to 0..K-1\n        remap = {lab: i for i, lab in enumerate(sorted(np.unique(new_labels)))}\n        labels = np.array([remap[x] for x in new_labels], dtype=np.int64)\n\n    return labels\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.351678Z","iopub.execute_input":"2026-02-13T17:39:52.351928Z","iopub.status.idle":"2026-02-13T17:39:52.365130Z","shell.execute_reply.started":"2026-02-13T17:39:52.351906Z","shell.execute_reply":"2026-02-13T17:39:52.364449Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\n\ndef reassign_small_clusters(\n    embs: np.ndarray,\n    labels: np.ndarray,\n    min_cluster_size: int = 2,\n) -> np.ndarray:\n    \"\"\"\n    Reassign windows belonging to tiny clusters to the nearest big-cluster centroid.\n    Works on L2-normalized embeddings (cosine similarity == dot product).\n    \"\"\"\n    if len(labels) == 0:\n        return labels\n\n    labels = labels.astype(int).copy()\n\n    uniq, counts = np.unique(labels, return_counts=True)\n    big = uniq[counts >= min_cluster_size]\n    small = uniq[counts < min_cluster_size]\n\n    # If everything is small, do nothing\n    if len(big) == 0 or len(small) == 0:\n        return labels\n\n    # L2 normalize for cosine\n    X = embs.astype(np.float32)\n    X = X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-8)\n\n    # centroids of big clusters\n    centroids = []\n    big_list = list(big)\n    for b in big_list:\n        idx = np.where(labels == b)[0]\n        c = X[idx].mean(axis=0)\n        c = c / (np.linalg.norm(c) + 1e-8)\n        centroids.append(c)\n    centroids = np.vstack(centroids)  # (B, D)\n\n    # reassign each small-cluster point\n    for s in small:\n        idx = np.where(labels == s)[0]\n        # cosine similarity to centroids\n        sims = X[idx] @ centroids.T  # (n_small, B)\n        nearest = np.argmax(sims, axis=1)\n        labels[idx] = np.array([big_list[j] for j in nearest], dtype=int)\n\n    return labels\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.365916Z","iopub.execute_input":"2026-02-13T17:39:52.366199Z","iopub.status.idle":"2026-02-13T17:39:52.379881Z","shell.execute_reply.started":"2026-02-13T17:39:52.366165Z","shell.execute_reply":"2026-02-13T17:39:52.379211Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from sklearn.decomposition import PCA\n\ndef plot_embedding_pca(embs, labels, title=\"Embedding PCA\"):\n    if len(embs) < 2:\n        print(\"Not enough embeddings to plot.\")\n        return\n    X = normalize(embs)\n    Z = PCA(n_components=2).fit_transform(X)\n\n    plt.figure(figsize=(6, 5))\n    plt.scatter(Z[:, 0], Z[:, 1], c=labels, s=18)\n    plt.title(title)\n    plt.xlabel(\"PC1\")\n    plt.ylabel(\"PC2\")\n    plt.grid(True)\n    plt.show()\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.380758Z","iopub.execute_input":"2026-02-13T17:39:52.381013Z","iopub.status.idle":"2026-02-13T17:39:52.396409Z","shell.execute_reply.started":"2026-02-13T17:39:52.380991Z","shell.execute_reply":"2026-02-13T17:39:52.395879Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def window_speech_ratio(ws: float, we: float, speech_segs: list[tuple[float,float]]) -> float:\n    \"\"\"\n    speech_segs: list of (start_sec, end_sec)\n    returns overlap_ratio in [0,1]\n    \"\"\"\n    dur = max(we - ws, 1e-6)\n    overlap = 0.0\n    for s, e in speech_segs:\n        a = max(ws, s)\n        b = min(we, e)\n        if b > a:\n            overlap += (b - a)\n    return overlap / dur","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.397197Z","iopub.execute_input":"2026-02-13T17:39:52.397955Z","iopub.status.idle":"2026-02-13T17:39:52.409339Z","shell.execute_reply.started":"2026-02-13T17:39:52.397914Z","shell.execute_reply":"2026-02-13T17:39:52.408712Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\n\ndef smooth_window_labels(labels: np.ndarray, radius: int = 3) -> np.ndarray:\n    \"\"\"\n    Majority-vote smoothing over a small neighborhood.\n    radius=1 means each label becomes majority of [i-1, i, i+1].\n    \"\"\"\n    if len(labels) == 0:\n        return labels\n    labels = labels.astype(int)\n    out = labels.copy()\n    for i in range(len(labels)):\n        a = max(0, i - radius)\n        b = min(len(labels), i + radius + 1)\n        window = labels[a:b]\n        # majority vote\n        vals, counts = np.unique(window, return_counts=True)\n        out[i] = vals[np.argmax(counts)]\n    return out\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.410117Z","iopub.execute_input":"2026-02-13T17:39:52.410369Z","iopub.status.idle":"2026-02-13T17:39:52.423603Z","shell.execute_reply.started":"2026-02-13T17:39:52.410341Z","shell.execute_reply":"2026-02-13T17:39:52.422966Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- One-file debug run: compute embs + labels for visualization ---\ntest_path = wav_paths[0]\n\nwav = load_audio_mono_16k(test_path, 16000)\nspeech_segs = silero_vad_speech_segments(wav, sr=16000)\nwindows = make_windows(speech_segs, win_sec=1.8, hop_sec=0.6)\n\nchunks = []\nvalid_windows = []\nfor ws, we in windows:\n    s = int(ws * 16000)\n    e = int(we * 16000)\n    chunk = wav[s:e]\n    if len(chunk) < int(0.4 * 16000):\n        continue\n    chunks.append(chunk.astype(np.float32, copy=False))\n    valid_windows.append((ws, we))\n\n# compute embeddings in safe micro-batches\nembs = ecapa_embeddings_batch(\n    chunks,\n    batch_size=8,       # reduce if OOM\n    max_chunk_sec=3.0   # keep small to avoid OOM\n) if len(chunks) else np.empty((0, 192), dtype=np.float32)\n\n# threshold clustering (no estimate_num_speakers)\nlabels = cluster_embeddings(\n    embs,\n    distance_threshold=0.62,  # tune later \n    max_speakers=25,\n    min_speakers=1\n) if len(embs) else np.array([], dtype=int)\n\n# smoothing before merging turns\nlabels = smooth_window_labels(labels, radius=3) if len(labels) else labels\n\nlen(embs), len(labels), (len(set(labels)) if len(labels) else 0)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:39:52.424373Z","iopub.execute_input":"2026-02-13T17:39:52.424598Z","iopub.status.idle":"2026-02-13T17:40:57.426865Z","shell.execute_reply.started":"2026-02-13T17:39:52.424578Z","shell.execute_reply":"2026-02-13T17:40:57.426216Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def merge_short_turns(turns, min_sec=0.7):\n    if not turns:\n        return turns\n    out = [list(turns[0])]\n    for s,e,spk in turns[1:]:\n        ps,pe,pp = out[-1]\n        if (e - s) < min_sec:\n            # absorb into previous\n            out[-1][1] = max(pe, e)\n        elif spk == pp:\n            out[-1][1] = max(pe, e)\n        else:\n            out.append([s,e,spk])\n    return [(float(a), float(b), c) for a,b,c in out]\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:40:57.427765Z","iopub.execute_input":"2026-02-13T17:40:57.428169Z","iopub.status.idle":"2026-02-13T17:40:57.433426Z","shell.execute_reply.started":"2026-02-13T17:40:57.428143Z","shell.execute_reply":"2026-02-13T17:40:57.432764Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def windows_to_turns(windows, labels, min_turn_sec=0.25, merge_gap_sec=0.45):\n    \"\"\"\n    Merge consecutive windows with the same label into turns.\n    merge_gap_sec should be small (0.10~0.30). Large values over-merge speakers.\n    \"\"\"\n    assert len(windows) == len(labels)\n    if len(windows) == 0:\n        return []\n\n    turns = []\n    cur_lab = int(labels[0])\n    cur_s, cur_e = windows[0]\n\n    for (s, e), lab in zip(windows[1:], labels[1:]):\n        lab = int(lab)\n        if lab == cur_lab and s <= cur_e + merge_gap_sec:\n            cur_e = max(cur_e, e)\n        else:\n            if (cur_e - cur_s) >= min_turn_sec:\n                turns.append((float(cur_s), float(cur_e), cur_lab))\n            cur_lab = lab\n            cur_s, cur_e = s, e\n\n    if (cur_e - cur_s) >= min_turn_sec:\n        turns.append((float(cur_s), float(cur_e), cur_lab))\n\n    return turns\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:40:57.434241Z","iopub.execute_input":"2026-02-13T17:40:57.434513Z","iopub.status.idle":"2026-02-13T17:40:57.448433Z","shell.execute_reply.started":"2026-02-13T17:40:57.434492Z","shell.execute_reply":"2026-02-13T17:40:57.447765Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import numpy as np\n\ndef _l2norm(X, eps=1e-9):\n    X = X.astype(np.float32)\n    return X / (np.linalg.norm(X, axis=1, keepdims=True) + eps)\n\ndef compute_centroids(embs, labels):\n    \"\"\"Centroid per speaker label (on L2-normalized embeddings).\"\"\"\n    E = _l2norm(embs)\n    labs = np.asarray(labels, dtype=np.int64)\n    uniq = np.unique(labs)\n    C = []\n    for u in uniq:\n        m = E[labs == u].mean(axis=0)\n        m = m / (np.linalg.norm(m) + 1e-9)\n        C.append(m)\n    C = np.stack(C, axis=0)  # [K, D]\n    return uniq, C\n\ndef viterbi_resegmentation(\n    embs,\n    labels_init,\n    transition_penalty=2.5,\n    scale=12.0,\n):\n    \"\"\"\n    Viterbi decode on cosine-sim emissions.\n    - transition_penalty: higher => fewer speaker switches\n    - scale: higher => trusts acoustic evidence more\n    \"\"\"\n    labs, C = compute_centroids(embs, labels_init)\n    E = _l2norm(embs)\n    # cosine similarity (since normalized): [T, K]\n    sim = E @ C.T\n\n    # emissions as log-prob-ish scores\n    # scale makes peaks sharper\n    emit = scale * sim\n\n    T, K = emit.shape\n    dp = np.zeros((T, K), dtype=np.float32)\n    back = np.zeros((T, K), dtype=np.int32)\n\n    dp[0] = emit[0]\n    back[0] = -1\n\n    for t in range(1, T):\n        # best previous state with transition penalty\n        # cost = dp[t-1, j] - penalty*(j != k)\n        prev = dp[t-1]\n        for k in range(K):\n            best = prev - transition_penalty\n            best[k] = prev[k]  # no penalty if staying\n            j = int(np.argmax(best))\n            dp[t, k] = best[j] + emit[t, k]\n            back[t, k] = j\n\n    path = np.zeros(T, dtype=np.int32)\n    path[T-1] = int(np.argmax(dp[T-1]))\n    for t in range(T-2, -1, -1):\n        path[t] = back[t+1, path[t+1]]\n\n    # map state index -> original label ids\n    out = labs[path]\n    return out.astype(np.int64)\n\ndef overlap_hysteresis_labels(embs, labels, margin=0.03):\n    \"\"\"\n    If top-2 cosine sims are too close => ambiguous/overlap region.\n    In those regions, keep previous label to stop ping-pong.\n    \"\"\"\n    labs, C = compute_centroids(embs, labels)\n    E = _l2norm(embs)\n    sim = E @ C.T  # [T, K]\n    order = np.argsort(sim, axis=1)\n    top1 = order[:, -1]\n    top2 = order[:, -2]\n    diff = sim[np.arange(len(sim)), top1] - sim[np.arange(len(sim)), top2]\n    ambiguous = diff < margin\n\n    # map from state idx to label id\n    top1_lab = labs[top1]\n\n    out = np.asarray(labels, dtype=np.int64).copy()\n    for i in range(1, len(out)):\n        if ambiguous[i]:\n            out[i] = out[i-1]\n        else:\n            out[i] = top1_lab[i]\n    return out\n\ndef enforce_min_run(labels, min_run=3):\n    \"\"\"\n    Remove very short runs (<= min_run-1) by absorbing into neighbors.\n    Works on window-level labels.\n    \"\"\"\n    y = np.asarray(labels, dtype=np.int64).copy()\n    n = len(y)\n    if n == 0:\n        return y\n\n    # run-length encoding\n    starts = [0]\n    for i in range(1, n):\n        if y[i] != y[i-1]:\n            starts.append(i)\n    starts.append(n)\n\n    for r in range(len(starts)-1):\n        s = starts[r]\n        e = starts[r+1]\n        run_len = e - s\n        if run_len < min_run:\n            left = y[s-1] if s > 0 else None\n            right = y[e] if e < n else None\n            if left is not None:\n                y[s:e] = left\n            elif right is not None:\n                y[s:e] = right\n    return y\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:40:57.449398Z","iopub.execute_input":"2026-02-13T17:40:57.449649Z","iopub.status.idle":"2026-02-13T17:40:57.470511Z","shell.execute_reply.started":"2026-02-13T17:40:57.449627Z","shell.execute_reply":"2026-02-13T17:40:57.469805Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def merge_close_segments(segments, max_gap=0.30):\n    \"\"\"Merge consecutive same-speaker turns with small gaps.\"\"\"\n    if not segments:\n        return []\n    merged = [segments[0]]\n    for s, e, spk in segments[1:]:\n        ls, le, lspk = merged[-1]\n        if spk == lspk and (s - le) <= max_gap:\n            merged[-1] = (ls, max(le, e), lspk)\n        else:\n            merged.append((s, e, spk))\n    return merged\n\n\ndef filter_short_segments(segments, min_duration=0.40):\n    \"\"\"Drop very short turns (often errors).\"\"\"\n    return [(s, e, spk) for (s, e, spk) in segments if (e - s) >= min_duration]\n\n\ndef resolve_overlaps_keep_first(segments):\n    \"\"\"\n    Competition rule: keep first speaker in overlap.\n    This enforces non-overlapping timeline by truncating later segments.\n    \"\"\"\n    if not segments:\n        return []\n    segs = sorted(segments, key=lambda x: (x[0], x[1]))\n    out = []\n    cur_end = -1e9\n    for s, e, spk in segs:\n        if s < cur_end:\n            s = cur_end\n        if e > s:\n            out.append((s, e, spk))\n            cur_end = max(cur_end, e)\n    return out\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T17:40:57.471354Z","iopub.execute_input":"2026-02-13T17:40:57.471601Z","iopub.status.idle":"2026-02-13T17:40:57.484870Z","shell.execute_reply.started":"2026-02-13T17:40:57.471581Z","shell.execute_reply":"2026-02-13T17:40:57.484407Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport numpy as np\nimport inspect\n\ndef diarize_file(\n    audio_path: str | Path,\n\n    # --- VAD knobs ---\n    vad_threshold: float = 0.45,   # start 0.45 (0.40 often adds too much garbage speech)\n    padding_ms: int = 300,\n    min_silence_ms: int = 300,\n    min_speech_ms: int = 250,\n\n    # --- windowing ---\n    win_sec: float = 1.8,\n    hop_sec: float = 0.6,\n\n    # --- clustering ---\n    distance_threshold: float = 0.80,\n    max_speakers: int = 25,\n\n    # --- post ---\n    min_cluster_size: int = 8,\n    smooth_radius: int = 1,\n    merge_gap_sec: float = 0.25,\n    min_turn_sec: float = 0.35,\n    min_segment_dur: float = 0.40,\n):\n    wav = load_audio_mono_16k(audio_path, 16000)\n\n    speech_segs = silero_vad_speech_segments(\n        wav,\n        sr=16000,\n        vad_threshold=vad_threshold,\n        padding_ms=padding_ms,\n        min_silence_ms=min_silence_ms,\n        min_speech_ms=min_speech_ms,\n    )\n    if not speech_segs:\n        return []\n\n    windows = make_windows(speech_segs, win_sec=win_sec, hop_sec=hop_sec)\n\n    # --- Build chunks for embedding ---\n    chunks = []\n    valid_windows = []\n    MIN_SPEECH_RATIO = 0.50\n\n    for ws, we in windows:\n        if window_speech_ratio(ws, we, speech_segs) < MIN_SPEECH_RATIO:\n            continue\n        s = int(ws * 16000)\n        e = int(we * 16000)\n        ch = wav[s:e]\n        if len(ch) < int(0.6 * 16000):\n            continue\n        chunks.append(ch)\n        valid_windows.append((ws, we))\n\n    if not chunks:\n        return []\n\n    # --- Embeddings (choose ONE approach) ---\n    # (A) Simple (often more stable):\n    # embs = ecapa_embeddings_batch(chunks).astype(np.float32)\n\n    # (B) Robust subchunks (your idea) — keep if it helped:\n    all_sub = []\n    owner = []\n    for i, ch in enumerate(chunks):\n        subs = make_subchunks(ch, sr=16000)  # must return list[np.ndarray or torch-ready]\n        for sc in subs:\n            all_sub.append(sc)\n            owner.append(i)\n\n    sub_embs = ecapa_embeddings_batch(all_sub).astype(np.float32)  # (M, D)\n    D = sub_embs.shape[1]\n    embs = np.zeros((len(chunks), D), dtype=np.float32)\n    cnt = np.zeros((len(chunks),), dtype=np.int32)\n    for k, idx in enumerate(owner):\n        embs[idx] += sub_embs[k]\n        cnt[idx] += 1\n    embs = embs / np.maximum(cnt[:, None], 1)\n\n    # --- CRITICAL: L2 normalize embeddings for cosine distance ---\n    embs = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)\n\n    # --- clustering (cap max speakers to avoid 500+ clusters) ---\n    labels_init = cluster_embeddings(\n        embs,\n        distance_threshold=distance_threshold,\n        max_speakers=max_speakers,\n        min_speakers=1,\n    ).astype(np.int64)\n\n    # --- anti-fragmentation ---\n    labels = reassign_small_clusters(embs, labels_init, min_cluster_size=min_cluster_size).astype(np.int64)\n\n    # --- light smoothing (don’t overdo) ---\n    labels = smooth_window_labels(labels, radius=smooth_radius).astype(np.int64)\n\n    # --- windows -> turns (signature-safe) ---\n    def windows_to_turns_safe(w, y, min_turn_sec=min_turn_sec, merge_gap_sec=merge_gap_sec):\n        sig = inspect.signature(windows_to_turns)\n        if \"merge_gap\" in sig.parameters:\n            return windows_to_turns(w, y, min_turn_sec=min_turn_sec, merge_gap=merge_gap_sec)\n        if \"merge_gap_sec\" in sig.parameters:\n            return windows_to_turns(w, y, min_turn_sec=min_turn_sec, merge_gap_sec=merge_gap_sec)\n        try:\n            return windows_to_turns(w, y, min_turn_sec, merge_gap_sec)\n        except TypeError:\n            return windows_to_turns(w, y, min_turn_sec)\n\n    turns = windows_to_turns_safe(valid_windows, labels)\n\n    # --- turn-level post-processing (order matters) ---\n    # 1) resolve overlaps (keep first)\n    turns = resolve_overlaps_keep_first(turns)\n\n    # 2) merge same-speaker separated by tiny gaps\n    turns = merge_close_segments(turns, max_gap=merge_gap_sec)\n\n    # 3) drop very short segments\n    turns = filter_short_segments(turns, min_duration=min_segment_dur)\n\n    # --- debug ---\n    # print(f\"THR {distance_threshold:.2f} | clusters {len(np.unique(labels_init))}->{len(np.unique(labels))} | turns {len(turns)}\")\n\n    # --- Map to SPEAKER_# in order of first appearance ---\n    label_map = {}\n    next_id = 1\n    out = []\n    for s, e, lab in turns:\n        lab = int(lab)\n        if lab not in label_map:\n            label_map[lab] = f\"SPEAKER_{next_id}\"\n            next_id += 1\n        out.append({\n            \"start_time\": round(float(s), 2),\n            \"end_time\": round(float(e), 2),\n            \"speaker_id\": label_map[lab],\n        })\n    return out\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T18:25:53.069834Z","iopub.execute_input":"2026-02-13T18:25:53.070497Z","iopub.status.idle":"2026-02-13T18:25:53.084056Z","shell.execute_reply.started":"2026-02-13T18:25:53.070465Z","shell.execute_reply":"2026-02-13T18:25:53.083368Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from pathlib import Path\nimport soundfile as sf\nfrom pyannote.core import Segment\nfrom pyannote.metrics.diarization import DiarizationErrorRate\n\nder_metric = DiarizationErrorRate()\n\nTRAIN_AUDIO_DIR = Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/train/audio\")\n\ngt_files = [\n    Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/train/annotation/train_001.csv\"),\n    Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/train/annotation/train_002.csv\"),\n    Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/train/annotation/train_008.csv\"),\n    Path(\"/kaggle/input/dl-sprint-4-0-bengali-speaker-diarization-challenge/diarization/diarization/train/annotation/train_013.csv\"),\n]\n\ndef audio_path_from_csv(csv_path: Path) -> Path:\n    return TRAIN_AUDIO_DIR / (csv_path.stem + \".wav\")\nfor th in [0.75, 0.80, 0.85, 0.90]:\n    print(\"LOOP threshold =\", th, \"| type =\", type(th))\n\ndef eval_der_on_files(csv_list, distance_threshold=0.70):\n    ders = []\n    for csv_path in csv_list:\n        gt = load_gt_annotation(csv_path)\n        wav_path = audio_path_from_csv(Path(csv_path))\n\n        pred_list = diarize_file(\n            wav_path,\n            distance_threshold=distance_threshold\n        )\n\n        hyp = pred_to_annotation(pred_list)\n\n        duration = sf.info(str(wav_path)).duration\n        uem = Segment(0.0, float(duration))\n\n        d = der_metric(gt, hyp, uem=uem)\n        ders.append(d)\n        print(f\"{Path(csv_path).name}: DER = {d:.4f} | #pred_turns={len(pred_list)}\")\n\n    mean_der = sum(ders) / len(ders) if ders else 1.0\n    print(f\"\\nMean DER over {len(ders)} files: {mean_der:.4f}\")\n    return mean_der\n\nfor th in [0.75, 0.80, 0.85, 0.90]:\n    print(\"=\"*50)\n    print(\"Testing Threshold:\", th)\n    mean_der = eval_der_on_files(gt_files, distance_threshold=th)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T18:25:55.314761Z","iopub.execute_input":"2026-02-13T18:25:55.315076Z","iopub.status.idle":"2026-02-13T18:44:58.645990Z","shell.execute_reply.started":"2026-02-13T18:25:55.315051Z","shell.execute_reply":"2026-02-13T18:44:58.645185Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"BEST_TH = 0.85\n\npred_rows = []\nfor stem, wav_path in tqdm(list(zip(stems, wav_paths)), total=len(wav_paths), desc=\"Diarizing\"):\n    diar = diarize_file(wav_path, distance_threshold=BEST_TH)\n    pred_rows.append({\n        \"filename\": stem,\n        \"diarization\": json.dumps(diar, ensure_ascii=False),\n    })\n\nsub_df = pd.DataFrame(pred_rows) \nsub_df.to_csv(OUT_PATH, index=False)\nOUT_PATH, sub_df.head()   ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-13T18:50:54.376544Z","iopub.execute_input":"2026-02-13T18:50:54.377314Z","iopub.status.idle":"2026-02-13T19:10:06.908253Z","shell.execute_reply.started":"2026-02-13T18:50:54.377280Z","shell.execute_reply":"2026-02-13T19:10:06.907291Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}