Phase 3 — Experiment 1: DCCE¶
Beyond WhAM · CS 297 Final Paper · April 2026¶
This notebook trains and evaluates the Dual-Channel Contrastive Encoder (DCCE) — the core contribution of this paper. DCCE is purpose-built around the known biological decomposition of sperm whale codas into two independent information channels (Leitão et al., 2023; Beguš et al., 2024):
| Channel | Biological signal | DCCE encoder | Input |
|---|---|---|---|
| Rhythm | Coda type / click timing pattern | 2-layer GRU | ICI sequence (length 9) |
| Spectral | Social / individual identity | Small CNN | Mel-spectrogram (64 × 128) |
The two channel embeddings are fused and trained with a cross-channel contrastive loss: the rhythm representation of coda A and the spectral representation of a different coda B from the same social unit form a positive pair. This forces the joint embedding to capture unit identity from orthogonal signal axes — the key novelty over WhAM, which learns representations as an emergent byproduct of a generative objective.
Training objective¶
L = L_contrastive(z) + λ1 · L_type(r_emb) + λ2 · L_id(s_emb)
- L_contrastive: NT-Xent (SimCLR, Chen et al. 2020) on the fused embedding z; τ=0.07
- L_type: cross-entropy on r_emb → coda type (22 classes) — rhythm supervision signal
- L_id: cross-entropy on s_emb → individual ID (762 labelled codas only) — spectral supervision
Comparison targets (from Phases 1–2)¶
| Task | Target | Source |
|---|---|---|
| Social Unit Macro-F1 | > 0.895 | WhAM L19 (best layer, Phase 2) |
| Individual ID Macro-F1 | > 0.454 | WhAM L10 (Phase 1) |
| Coda Type Macro-F1 | > 0.931 | Raw ICI baseline (1A) |
1. Setup¶
import subprocess, sys
def ensure_installed(package, pip_name=None):
try:
__import__(package)
except ImportError:
print(f"Installing {pip_name or package}...")
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name or package, "-q"])
ensure_installed("torch", "torch")
ensure_installed("librosa")
ensure_installed("umap", "umap-learn")
print("All dependencies available.")
/opt/miniconda3/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
All dependencies available.
import os, warnings, time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
import librosa
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import f1_score, accuracy_score, classification_report
warnings.filterwarnings("ignore")
%matplotlib inline
plt.rcParams.update({"figure.dpi": 120, "font.size": 11})
# Notebook lives in notebooks/; project root is one level up
BASE = os.path.abspath(os.path.join(os.getcwd(), ".."))
DATA = os.path.join(BASE, "datasets")
AUDIO = os.path.join(DATA, "dswp_audio")
FIGS = os.path.join(BASE, "figures", "phase3")
os.makedirs(FIGS, exist_ok=True)
UNIT_COLORS = {"A": "#4C72B0", "D": "#DD8452", "F": "#55A868"}
SEED = 42
torch.manual_seed(SEED); np.random.seed(SEED)
DEVICE = ("mps" if torch.backends.mps.is_available()
else "cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")
print(f"PyTorch: {torch.__version__}")
Device: mps PyTorch: 2.8.0
# ── Load labels and pre-computed features ─────────────────────────────────────
from sklearn.model_selection import StratifiedShuffleSplit
df = pd.read_csv(os.path.join(DATA, "dswp_labels.csv"))
df["ici_list"] = df["ici_sequence"].apply(
lambda s: [float(x) for x in s.split("|")] if isinstance(s, str) and s else [])
df_clean = df[df["is_noise"] == 0].copy().reset_index(drop=True)
df_id_all = df_clean[df_clean["individual_id"] != "0"].copy()
id_counts = df_id_all["individual_id"].value_counts()
df_id = df_id_all[df_id_all["individual_id"].isin(
id_counts[id_counts > 1].index)].copy().reset_index(drop=True)
# Generate train/test splits (Phase 1 didn't provide valid .npy files)
def make_split(data, stratify_col, test_size=0.2, seed=SEED):
sss = StratifiedShuffleSplit(n_splits=1, test_size=test_size, random_state=seed)
idx = np.arange(len(data))
train_idx, test_idx = next(sss.split(idx, data[stratify_col]))
return train_idx, test_idx
train_idx, test_idx = make_split(df_clean, "unit")
train_id_idx, test_id_idx = make_split(df_id, "individual_id")
df_train = df_clean.iloc[train_idx].reset_index(drop=True)
df_test = df_clean.iloc[test_idx].reset_index(drop=True)
df_id_train = df_id.iloc[train_id_idx].reset_index(drop=True)
df_id_test = df_id.iloc[test_id_idx].reset_index(drop=True)
# Note: X_mel_all will be computed in next cell from full 2D mel-spectrograms
print(f"Clean codas : {len(df_clean)}")
print(f"IDN-labeled codas : {len(df_id)} ({df_id['individual_id'].nunique()} individuals)")
print(f"Train/test split : {len(df_train)} / {len(df_test)}")
print(f"ID train/test split : {len(df_id_train)} / {len(df_id_test)}")
Clean codas : 1383 IDN-labeled codas : 762 (12 individuals) Train/test split : 1106 / 277 ID train/test split : 609 / 153
2. Data Pipeline¶
ICI sequences and mel-spectrograms¶
The DCCE needs two input representations per coda:
- ICI vector (9d) — already in labels; zero-padded, StandardScaler normalised
- Mel-spectrogram (64 × 128) — loaded from WAV, fixed time window
For the spectral encoder we use the full 2D mel-spectrogram (not mean-pooled), so the CNN can exploit temporal structure across clicks within a coda. Codas shorter than 128 frames are zero-padded on the right; longer ones are truncated.
Cross-channel contrastive pairs¶
During training, positive pairs are constructed as:
- (rhythm_A, spectral_B) where A ≠ B but same social unit
- This forces the model to learn unit-level structure from both channels independently
For a batch of N codas we draw N matched-unit partners, giving 2N views for the NT-Xent loss.
# ── ICI feature matrix ────────────────────────────────────────────────────────
MAX_ICI = 9
def build_ici_matrix(data):
X = np.zeros((len(data), MAX_ICI), dtype=np.float32)
for i, row in enumerate(data.itertuples()):
for j, v in enumerate(row.ici_list[:MAX_ICI]):
X[i, j] = v
return X
X_ici_all = build_ici_matrix(df_clean)
scaler_ici = StandardScaler()
X_ici_all = scaler_ici.fit_transform(X_ici_all).astype(np.float32)
print(f"ICI matrix shape : {X_ici_all.shape} (scaled)")
# ── Mel-spectrograms (load from WAV for full 2D) ──────────────────────────────
N_MELS = 64
FMAX = 8000
N_FRAMES = 128 # fixed time dimension — pad/truncate
mel_path = os.path.join(DATA, "X_mel_full.npy")
if os.path.exists(mel_path):
X_mel_full = np.load(mel_path)
print(f"Mel full loaded : {X_mel_full.shape}")
else:
print("Computing 2D mel-spectrograms for all clean codas (takes ~4-5 min)...")
X_mel_full = np.zeros((len(df_clean), N_MELS, N_FRAMES), dtype=np.float32)
for i, row in enumerate(df_clean.itertuples()):
y, sr = librosa.load(os.path.join(AUDIO, f"{row.coda_id}.wav"),
sr=None, mono=True)
mel = librosa.feature.melspectrogram(y=y, sr=sr, n_mels=N_MELS, fmax=FMAX)
mel_db = librosa.power_to_db(mel, ref=np.max).astype(np.float32)
T = mel_db.shape[1]
if T >= N_FRAMES:
X_mel_full[i] = mel_db[:, :N_FRAMES]
else:
X_mel_full[i, :, :T] = mel_db
np.save(mel_path, X_mel_full)
print(f"Saved: {mel_path} shape={X_mel_full.shape}")
ICI matrix shape : (1383, 9) (scaled) Mel full loaded : (1383, 64, 128)
# ── Label encoders ────────────────────────────────────────────────────────────
from sklearn.preprocessing import LabelEncoder
le_unit = LabelEncoder().fit(df_clean["unit"])
le_type = LabelEncoder().fit(df_clean["coda_type"])
le_id = LabelEncoder().fit(df_id["individual_id"])
n_units = len(le_unit.classes_)
n_types = len(le_type.classes_)
n_ids = len(le_id.classes_)
y_unit = le_unit.transform(df_clean["unit"])
y_type = le_type.transform(df_clean["coda_type"])
# individual ID labels aligned to df_id
y_id = le_id.transform(df_id["individual_id"])
# index map: df_id row → df_clean row (for cross-dataset indexing)
id_in_clean = df_id["coda_id"].apply(
lambda c: df_clean[df_clean["coda_id"]==c].index[0]).values
print(f"Unit classes : {list(le_unit.classes_)} ({n_units})")
print(f"Coda type classes : {n_types}")
print(f"Individual ID classes: {n_ids}")
# Unit index arrays per clean coda — used for positive pair sampling
unit_indices = {u: np.where(y_unit == le_unit.transform([u])[0])[0]
for u in le_unit.classes_}
print("\nUnit sizes (train):")
for u, idx in unit_indices.items():
in_train = np.isin(idx, train_idx)
print(f" Unit {u}: {in_train.sum()} train {(~in_train).sum()} test")
Unit classes : ['A', 'D', 'F'] (3) Coda type classes : 22 Individual ID classes: 12 Unit sizes (train): Unit A: 193 train 48 test Unit D: 257 train 64 test Unit F: 656 train 165 test
3. DCCE Architecture¶
Rhythm Encoder¶
A 2-layer GRU processes the 9-dimensional ICI sequence as a temporal signal — each ICI value is one time step. The GRU final hidden state (both layers concatenated and projected) produces the 64-dimensional rhythm embedding r_emb.
GRU is chosen over Transformer for the rhythm encoder because the ICI sequence is very short (≤9 steps) and the ordering is meaningful (click 1 → 2 → 3 is a causal rhythm pattern). GRU captures this sequential structure with far fewer parameters than a self-attention mechanism.
Spectral Encoder¶
A small 3-block CNN processes the 64×128 mel-spectrogram. Each block is: Conv2d → BatchNorm → ReLU → MaxPool2d. The final representation is flattened and projected to 64 dimensions.
The CNN is shallow by design — we want the spectral encoder to learn features from the small DSWP dataset (1,106 training codas) without overfitting. Regularisation: Dropout(0.3) before the final projection.
Fusion MLP¶
concat(r_emb, s_emb) → LayerNorm → Linear(128→64) → ReLU → Linear(64→64) → z
class RhythmEncoder(nn.Module):
"""2-layer GRU on ICI sequence → 64-d embedding."""
def __init__(self, input_dim=9, hidden_dim=64, out_dim=64):
super().__init__()
self.gru = nn.GRU(input_size=1, hidden_size=hidden_dim,
num_layers=2, batch_first=True,
dropout=0.2, bidirectional=False)
self.proj = nn.Sequential(
nn.Linear(hidden_dim, out_dim),
nn.LayerNorm(out_dim),
)
self.input_dim = input_dim
def forward(self, x):
# x: (B, 9) → (B, 9, 1) for GRU
x = x.unsqueeze(-1)
_, h = self.gru(x) # h: (2, B, hidden)
h = h[-1] # last layer: (B, hidden)
return self.proj(h) # (B, out_dim)
class SpectralEncoder(nn.Module):
"""Small CNN on mel-spectrogram (64 × 128) → 64-d embedding."""
def __init__(self, n_mels=64, n_frames=128, out_dim=64):
super().__init__()
self.conv = nn.Sequential(
# Block 1: 1 → 16
nn.Conv2d(1, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(),
nn.MaxPool2d(2, 2), # → (16, 32, 64)
# Block 2: 16 → 32
nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
nn.MaxPool2d(2, 2), # → (32, 16, 32)
# Block 3: 32 → 64
nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(2, 2), # → (64, 8, 16)
)
self.drop = nn.Dropout(0.3)
flat_dim = 64 * (n_mels // 8) * (n_frames // 8)
self.proj = nn.Sequential(
nn.Linear(flat_dim, 128), nn.ReLU(),
nn.Linear(128, out_dim), nn.LayerNorm(out_dim),
)
def forward(self, x):
# x: (B, 64, 128)
x = x.unsqueeze(1) # (B, 1, 64, 128)
x = self.conv(x)
x = self.drop(x.flatten(1))
return self.proj(x) # (B, out_dim)
class DCCE(nn.Module):
"""Dual-Channel Contrastive Encoder."""
def __init__(self, n_types=22, n_ids=12, emb_dim=64):
super().__init__()
self.rhythm_enc = RhythmEncoder(out_dim=emb_dim)
self.spectral_enc = SpectralEncoder(out_dim=emb_dim)
self.fusion = nn.Sequential(
nn.LayerNorm(emb_dim * 2),
nn.Linear(emb_dim * 2, emb_dim),
nn.ReLU(),
nn.Linear(emb_dim, emb_dim),
)
# Auxiliary heads
self.head_type = nn.Linear(emb_dim, n_types)
self.head_id = nn.Linear(emb_dim, n_ids)
def forward(self, ici, mel):
r_emb = self.rhythm_enc(ici) # (B, 64)
s_emb = self.spectral_enc(mel) # (B, 64)
z = self.fusion(torch.cat([r_emb, s_emb], dim=1)) # (B, 64)
return z, r_emb, s_emb
def encode_rhythm(self, ici):
return self.rhythm_enc(ici)
def encode_spectral(self, mel):
return self.spectral_enc(mel)
# Sanity check
model = DCCE(n_types=n_types, n_ids=n_ids).to(DEVICE)
dummy_ici = torch.zeros(4, 9).to(DEVICE)
dummy_mel = torch.zeros(4, N_MELS, N_FRAMES).to(DEVICE)
z, r, s = model(dummy_ici, dummy_mel)
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"DCCE architecture OK")
print(f" z shape: {z.shape} r_emb: {r.shape} s_emb: {s.shape}")
print(f" Total parameters: {n_params:,}")
DCCE architecture OK z shape: torch.Size([4, 64]) r_emb: torch.Size([4, 64]) s_emb: torch.Size([4, 64]) Total parameters: 1,137,602
4. Loss Functions¶
NT-Xent Contrastive Loss (SimCLR)¶
Given a batch of N codas, we build 2N views using cross-channel pairing: for each coda i, we find a unit-matched partner j and construct (rhythm_i, spectral_j) as a positive pair. The NT-Xent loss maximises agreement between positive pairs while pushing all other pairs apart.
$$\mathcal{L}_{\text{NT-Xent}} = -\frac{1}{2N} \sum_{i=1}^{2N} \log \frac{\exp(\text{sim}(z_i, z_{i^+})/\tau)}{\sum_{k \neq i} \exp(\text{sim}(z_i, z_k)/\tau)}$$
Temperature τ=0.07 is used following Chen et al. (2020).
def nt_xent_loss(z1, z2, temperature=0.07):
"""
NT-Xent contrastive loss between two sets of embeddings.
z1, z2: (B, D) — positive pairs are (z1[i], z2[i]).
"""
B = z1.shape[0]
z = torch.cat([z1, z2], dim=0) # (2B, D)
z = F.normalize(z, dim=1)
sim = torch.mm(z, z.T) / temperature # (2B, 2B)
# Mask out self-similarity
mask = torch.eye(2*B, dtype=torch.bool, device=z.device)
sim.masked_fill_(mask, float('-inf'))
# Positive indices: i ↔ i+B
labels = torch.cat([torch.arange(B, 2*B), torch.arange(B)]).to(z.device)
return F.cross_entropy(sim, labels)
def combined_loss(z, r_emb, s_emb, z_partner, type_labels, id_labels,
model, lambda1=0.5, lambda2=0.5):
"""
Full DCCE loss:
L = L_contrastive(z, z_partner) + λ1·L_type(r_emb) + λ2·L_id(s_emb)
id_labels=None when no IDN-labeled codas are in the batch.
"""
l_contrast = nt_xent_loss(z, z_partner)
l_type = F.cross_entropy(model.head_type(r_emb), type_labels)
l_total = l_contrast + lambda1 * l_type
if id_labels is not None:
l_id = F.cross_entropy(model.head_id(s_emb), id_labels)
l_total = l_total + lambda2 * l_id
return l_total, l_contrast.item()
# Quick sanity check
model.train()
B = 8
z1 = torch.randn(B, 64).to(DEVICE)
z2 = torch.randn(B, 64).to(DEVICE)
loss_test = nt_xent_loss(z1, z2)
print(f"NT-Xent loss sanity check: {loss_test.item():.4f} (expected ~log(2B-1) ≈ {float(torch.log(torch.tensor(2*B-1.0))):.4f})")
NT-Xent loss sanity check: 3.9239 (expected ~log(2B-1) ≈ 2.7081)
5. Dataset and DataLoader¶
The CodaDataset returns (ici, mel, unit_label, type_label, id_label) for each coda. For training, we use WeightedRandomSampler to balance unit representation per batch (compensates for Unit F = 59.4%). The sampler assigns sample weights inversely proportional to unit frequency.
The cross-channel partner for each coda is sampled at batch construction time in the training loop — this keeps the Dataset simple and the partner sampling flexible.
class CodaDataset(Dataset):
def __init__(self, indices, X_ici, X_mel, y_unit, y_type,
y_id=None, id_clean_idx=None):
"""
indices : positions into the 1383-coda df_clean arrays
X_ici : (1383, 9) all clean codas
X_mel : (1383, 64, 128) all clean codas
y_unit/type : (1383,) all clean codas
y_id : (762,) IDN-labeled codas only (or None)
id_clean_idx: mapping from id subset → clean subset index
"""
self.idx = indices
self.X_ici = X_ici
self.X_mel = X_mel
self.y_unit = y_unit
self.y_type = y_type
self.y_id = y_id
self.id_clean_idx = id_clean_idx
def __len__(self): return len(self.idx)
def __getitem__(self, i):
ci = self.idx[i] # index into full clean array
ici = torch.tensor(self.X_ici[ci])
mel = torch.tensor(self.X_mel[ci])
u = torch.tensor(self.y_unit[ci], dtype=torch.long)
t = torch.tensor(self.y_type[ci], dtype=torch.long)
id_label = torch.tensor(-1, dtype=torch.long) # default: no ID label
if self.y_id is not None and self.id_clean_idx is not None:
# check if this clean index has an IDN label
match = np.where(self.id_clean_idx == ci)[0]
if len(match) > 0:
id_label = torch.tensor(self.y_id[match[0]], dtype=torch.long)
return ici, mel, u, t, id_label
# ── Build datasets ────────────────────────────────────────────────────────────
ds_train = CodaDataset(train_idx, X_ici_all, X_mel_full, y_unit, y_type,
y_id, id_in_clean)
ds_test = CodaDataset(test_idx, X_ici_all, X_mel_full, y_unit, y_type)
# Weighted sampler: inverse class frequency per unit
unit_counts = np.bincount(y_unit[train_idx], minlength=n_units).astype(float)
sample_weights = (1.0 / unit_counts)[y_unit[train_idx]]
sampler = WeightedRandomSampler(weights=sample_weights,
num_samples=len(ds_train), replacement=True)
BATCH_SIZE = 64
train_loader = DataLoader(ds_train, batch_size=BATCH_SIZE, sampler=sampler,
num_workers=0, drop_last=True)
test_loader = DataLoader(ds_test, batch_size=BATCH_SIZE, shuffle=False,
num_workers=0)
print(f"Train dataset: {len(ds_train)} codas | {len(train_loader)} batches of {BATCH_SIZE}")
print(f"Test dataset: {len(ds_test)} codas | {len(test_loader)} batches")
# Pre-build unit → train_indices lookup for partner sampling
unit_train_map = {}
for u_idx in range(n_units):
mask = y_unit[train_idx] == u_idx
unit_train_map[u_idx] = np.where(mask)[0] # positions within train_idx
Train dataset: 1106 codas | 17 batches of 64 Test dataset: 277 codas | 5 batches
6. Training¶
We train 4 model variants for the ablation study:
| Variant | Encoders active | Cross-channel aug |
|---|---|---|
rhythm_only |
GRU only — z = r_emb | N/A |
spectral_only |
CNN only — z = s_emb | N/A |
late_fusion |
GRU + CNN, standard pair (same coda both channels) | No |
full |
GRU + CNN, cross-channel partner from same unit | Yes |
All variants share the same architecture, loss, and hyperparameters. The difference is only in how positive pairs are constructed and which encoders contribute to z.
Training runs for 50 epochs on CPU/MPS. With 17 batches/epoch this is fast (~5-8 min per model).
def sample_partner_indices(unit_labels_batch, unit_train_map, train_idx, rng):
"""
For each item in the batch, sample a different coda from the same unit.
Returns global clean-array indices for the partners.
"""
partners = []
for u in unit_labels_batch.cpu().numpy():
candidates = unit_train_map[u]
# sample until we get a different position (avoid self-pairing in same batch)
p = rng.choice(candidates)
partners.append(train_idx[p])
return np.array(partners)
def train_dcce(variant="full", n_epochs=50, lr=1e-3, lambda1=0.5, lambda2=0.5):
"""
Train one DCCE variant.
variant: 'full' | 'late_fusion' | 'rhythm_only' | 'spectral_only'
Returns trained model and loss history.
"""
# Fix seed per variant so each model's initialisation is independent
# of training order — ensures reproducibility across reruns.
torch.manual_seed(SEED)
np.random.seed(SEED)
model = DCCE(n_types=n_types, n_ids=n_ids).to(DEVICE)
opt = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=n_epochs)
rng = np.random.default_rng(SEED)
history = []
model.train()
t0 = time.time()
for epoch in range(n_epochs):
epoch_loss = 0.0; n_batches = 0
for ici, mel, u_labels, t_labels, id_labels in train_loader:
ici = ici.to(DEVICE); mel = mel.to(DEVICE)
u_labels = u_labels.to(DEVICE)
t_labels = t_labels.to(DEVICE)
# ── Forward pass (variant-specific) ───────────────────────────
if variant == "rhythm_only":
r_emb = model.encode_rhythm(ici)
z = r_emb
z_partner = r_emb # no spectral; self-pairing cancels via NT-Xent mask
s_emb = r_emb # proxy for combined_loss signature
elif variant == "spectral_only":
s_emb = model.encode_spectral(mel)
z = s_emb
z_partner = s_emb
r_emb = s_emb
elif variant == "late_fusion":
# Same-coda pair: rhythm and spectral of the same audio
z, r_emb, s_emb = model(ici, mel)
z_partner = z.detach().clone() # treat coda as its own partner
else: # "full" — cross-channel
z, r_emb, s_emb = model(ici, mel)
# Sample partners from same unit
p_idx = sample_partner_indices(u_labels, unit_train_map, train_idx, rng)
p_ici = torch.tensor(X_ici_all[p_idx]).to(DEVICE)
p_mel = torch.tensor(X_mel_full[p_idx]).to(DEVICE)
z_partner, _, _ = model(p_ici, p_mel)
# ── Loss ───────────────────────────────────────────────────────
valid_id = id_labels >= 0
id_for_loss = id_labels[valid_id].to(DEVICE) if valid_id.any() else None
if variant in ("rhythm_only", "spectral_only"):
# No contrastive loss — only auxiliary heads
l_type = F.cross_entropy(model.head_type(r_emb), t_labels)
loss = l_type
if id_for_loss is not None:
loss = loss + lambda2 * F.cross_entropy(
model.head_id(s_emb[valid_id.to(DEVICE)]), id_for_loss)
else:
# NT-Xent + auxiliary heads
l_type = F.cross_entropy(model.head_type(r_emb), t_labels)
loss = nt_xent_loss(z, z_partner) + lambda1 * l_type
if id_for_loss is not None:
loss = loss + lambda2 * F.cross_entropy(
model.head_id(s_emb[valid_id.to(DEVICE)]), id_for_loss)
opt.zero_grad(); loss.backward(); opt.step()
epoch_loss += loss.item(); n_batches += 1
sched.step()
avg_loss = epoch_loss / n_batches
history.append(avg_loss)
if (epoch + 1) % 10 == 0:
elapsed = time.time() - t0
print(f" [{variant}] Epoch {epoch+1:3d}/{n_epochs} loss={avg_loss:.4f} "
f"({elapsed:.0f}s elapsed)")
print(f" [{variant}] Training complete in {time.time()-t0:.0f}s")
return model, history
# ── Train all 4 variants ──────────────────────────────────────────────────────
# Each takes ~5-8 min on MPS / ~15-20 min on CPU
trained_models = {}
loss_histories = {}
for variant in ["rhythm_only", "spectral_only", "late_fusion", "full"]:
print(f"\n{'='*55}")
print(f"Training: DCCE-{variant}")
print(f"{'='*55}")
m, h = train_dcce(variant=variant, n_epochs=50)
trained_models[variant] = m
loss_histories[variant] = h
print("\nAll variants trained.")
======================================================= Training: DCCE-rhythm_only ======================================================= [rhythm_only] Epoch 10/50 loss=1.1114 (8s elapsed) [rhythm_only] Epoch 20/50 loss=0.6665 (11s elapsed) [rhythm_only] Epoch 30/50 loss=0.5446 (14s elapsed) [rhythm_only] Epoch 40/50 loss=0.5261 (17s elapsed) [rhythm_only] Epoch 50/50 loss=0.4721 (19s elapsed) [rhythm_only] Training complete in 19s ======================================================= Training: DCCE-spectral_only ======================================================= [spectral_only] Epoch 10/50 loss=2.0407 (8s elapsed) [spectral_only] Epoch 20/50 loss=0.8830 (14s elapsed) [spectral_only] Epoch 30/50 loss=0.3680 (20s elapsed) [spectral_only] Epoch 40/50 loss=0.2810 (26s elapsed) [spectral_only] Epoch 50/50 loss=0.2415 (32s elapsed) [spectral_only] Training complete in 32s ======================================================= Training: DCCE-late_fusion ======================================================= [late_fusion] Epoch 10/50 loss=1.0388 (7s elapsed) [late_fusion] Epoch 20/50 loss=0.4970 (14s elapsed) [late_fusion] Epoch 30/50 loss=0.2341 (21s elapsed) [late_fusion] Epoch 40/50 loss=0.1427 (27s elapsed) [late_fusion] Epoch 50/50 loss=0.1181 (34s elapsed) [late_fusion] Training complete in 34s ======================================================= Training: DCCE-full ======================================================= [full] Epoch 10/50 loss=5.0106 (12s elapsed) [full] Epoch 20/50 loss=4.3457 (24s elapsed) [full] Epoch 30/50 loss=3.9999 (36s elapsed) [full] Epoch 40/50 loss=3.9086 (49s elapsed) [full] Epoch 50/50 loss=3.8741 (61s elapsed) [full] Training complete in 61s All variants trained.
# ── Save trained models for future use ──────────────────────────────────────
import os
MODELS = os.path.join(BASE, "models")
os.makedirs(MODELS, exist_ok=True)
print("Saving trained models...")
for variant, model in trained_models.items():
model_path = os.path.join(MODELS, f"dcce_{variant}.pth")
torch.save(model.state_dict(), model_path)
print(f"Saved: {model_path}")
print(f"\nModels saved to: {MODELS}")
print("These can be loaded in other notebooks for inference.")
Saving trained models... Saved: /Users/emmavirnelli/CS 297/data_297_final_paper/models/dcce_rhythm_only.pth Saved: /Users/emmavirnelli/CS 297/data_297_final_paper/models/dcce_spectral_only.pth Saved: /Users/emmavirnelli/CS 297/data_297_final_paper/models/dcce_late_fusion.pth Saved: /Users/emmavirnelli/CS 297/data_297_final_paper/models/dcce_full.pth Models saved to: /Users/emmavirnelli/CS 297/data_297_final_paper/models These can be loaded in other notebooks for inference.
# ── Plot training loss curves ─────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(10, 4))
colors = {"rhythm_only": "#4C72B0", "spectral_only": "#DD8452",
"late_fusion": "#9370DB", "full": "#55A868"}
for variant, hist in loss_histories.items():
ax.plot(hist, lw=2, color=colors[variant], label=f"DCCE-{variant}")
ax.set_xlabel("Epoch"); ax.set_ylabel("Training Loss")
ax.set_title("DCCE Training Loss Curves — All 4 Variants")
ax.legend(fontsize=9); ax.grid(alpha=0.25)
plt.tight_layout()
plt.savefig(os.path.join(FIGS, "fig_dcce_training_curves.png"), dpi=130, bbox_inches="tight")
plt.show()
7. Evaluation — Linear Probe on Frozen Embeddings¶
Following the standard representation learning evaluation protocol (Chen et al. 2020; Radford et al. 2021), we freeze each trained DCCE and fit a logistic regression probe on top of the joint embedding z. This tests whether the representation is useful, decoupled from the quality of the auxiliary classifier heads trained during DCCE.
For rhythm-only and spectral-only ablations, z = r_emb or s_emb respectively. For late_fusion and full, z is the fused 64-dimensional embedding.
Same train/test split, same evaluation protocol (macro-F1 primary) as Phases 1–2.
def extract_embeddings(model, variant, indices):
"""Extract z embeddings for given indices (into df_clean arrays)."""
model.eval()
embs = []
with torch.no_grad():
for i in range(0, len(indices), BATCH_SIZE):
batch_idx = indices[i:i+BATCH_SIZE]
ici = torch.tensor(X_ici_all[batch_idx]).to(DEVICE)
mel = torch.tensor(X_mel_full[batch_idx]).to(DEVICE)
if variant == "rhythm_only":
z = model.encode_rhythm(ici)
elif variant == "spectral_only":
z = model.encode_spectral(mel)
else:
z, _, _ = model(ici, mel)
embs.append(z.cpu().numpy())
return np.vstack(embs)
def evaluate_model(model, variant, label=""):
"""Run all 3 linear probes and return result dict."""
Z_tr = extract_embeddings(model, variant, train_idx)
Z_te = extract_embeddings(model, variant, test_idx)
# ID subset
id_train_clean = id_in_clean[train_id_idx]
id_test_clean = id_in_clean[test_id_idx]
# re-index into train_idx / test_idx arrays for embedding extraction
Z_id_tr = extract_embeddings(model, variant, id_train_clean)
Z_id_te = extract_embeddings(model, variant, id_test_clean)
sc = StandardScaler()
X_tr = sc.fit_transform(Z_tr); X_te = sc.transform(Z_te)
sc_id = StandardScaler()
X_id_tr = sc_id.fit_transform(Z_id_tr); X_id_te = sc_id.transform(Z_id_te)
make_lr = lambda: LogisticRegression(max_iter=2000, class_weight="balanced",
random_state=SEED, solver="lbfgs")
results = {}
# Social unit
lr = make_lr().fit(X_tr, y_unit[train_idx])
pred = lr.predict(X_te)
results["unit"] = {"macro_f1": f1_score(y_unit[test_idx], pred, average="macro", zero_division=0),
"accuracy": accuracy_score(y_unit[test_idx], pred)}
# Coda type
lr = make_lr().fit(X_tr, y_type[train_idx])
pred = lr.predict(X_te)
results["coda_type"] = {"macro_f1": f1_score(y_type[test_idx], pred, average="macro", zero_division=0),
"accuracy": accuracy_score(y_type[test_idx], pred)}
# Individual ID
lr = make_lr().fit(X_id_tr, y_id[train_id_idx])
pred = lr.predict(X_id_te)
results["individual_id"] = {"macro_f1": f1_score(y_id[test_id_idx], pred, average="macro", zero_division=0),
"accuracy": accuracy_score(y_id[test_id_idx], pred)}
print(f"\n{'='*55}")
print(f"DCCE-{variant} {label}")
for task, r in results.items():
print(f" {task:25s} F1={r['macro_f1']:.4f} Acc={r['accuracy']:.4f}")
return results
all_results = {}
for variant, model in trained_models.items():
all_results[f"DCCE-{variant}"] = evaluate_model(model, variant)
======================================================= DCCE-rhythm_only unit F1=0.6366 Acc=0.6787 coda_type F1=0.8777 Acc=0.9856 individual_id F1=0.5087 Acc=0.5033 ======================================================= DCCE-spectral_only unit F1=0.7216 Acc=0.7437 coda_type F1=0.1720 Acc=0.3791 individual_id F1=0.7914 Acc=0.8039 ======================================================= DCCE-late_fusion unit F1=0.6617 Acc=0.6679 coda_type F1=0.8028 Acc=0.9242 individual_id F1=0.7155 Acc=0.8758 ======================================================= DCCE-full unit F1=0.8656 Acc=0.8700 coda_type F1=0.5438 Acc=0.7653 individual_id F1=0.7737 Acc=0.8824
8. Full Comparison: DCCE Ablations vs All Baselines¶
# ── Load Phase 1 baseline results (live, not hardcoded) ──────────────────────
p1_csv = os.path.join(DATA, "phase1_results.csv")
use_hardcoded = True
try:
if os.path.exists(p1_csv) and os.path.getsize(p1_csv) > 500:
p1_df = pd.read_csv(p1_csv)
if "model" in p1_df.columns and "task" in p1_df.columns:
def _get(model, task, metric):
row = p1_df[(p1_df["model"] == model) & (p1_df["task"] == task)]
return float(row[metric].iloc[0]) if len(row) else None
baseline_results = {
"1A — Raw ICI": {
t: (_get("1A_ICI", t, "macro_f1"), _get("1A_ICI", t, "accuracy"))
for t in ["unit", "coda_type", "individual_id"]},
"1C — Raw Mel": {
t: (_get("1C_Mel", t, "macro_f1"), _get("1C_Mel", t, "accuracy"))
for t in ["unit", "coda_type", "individual_id"]},
"1B — WhAM L10": {
t: (_get("1B_WhAM_L10", t, "macro_f1"), _get("1B_WhAM_L10", t, "accuracy"))
for t in ["unit", "coda_type", "individual_id"]},
"1B — WhAM L19": {
t: (_get("1B_WhAM_L19", t, "macro_f1"), None)
for t in ["unit", "coda_type", "individual_id"]},
}
use_hardcoded = False
print("Baseline results loaded from phase1_results.csv")
except Exception as e:
print(f"Warning: Could not load phase1_results.csv ({type(e).__name__})")
if use_hardcoded:
baseline_results = {
"1A — Raw ICI": {"unit": (0.5986, 0.6209), "coda_type": (0.9310, 0.9856), "individual_id": (0.4925, 0.5033)},
"1C — Raw Mel": {"unit": (0.7396, 0.7329), "coda_type": (0.0972, 0.1372), "individual_id": (0.2722, 0.2745)},
"1B — WhAM L10": {"unit": (0.8763, 0.8809), "coda_type": (0.2120, 0.4007), "individual_id": (0.4535, 0.4641)},
"1B — WhAM L19": {"unit": (0.8946, None), "coda_type": (0.2605, None), "individual_id": (0.4257, None)},
}
print("Using hardcoded Phase 1 baseline results")
rows = []
for name, res in baseline_results.items():
for task, (f1, acc) in res.items():
rows.append({"Model": name, "Task": task.replace("_"," ").title(),
"Macro-F1": f"{f1:.4f}", "Accuracy": f"{acc:.4f}" if acc else "—"})
for name, res in all_results.items():
for task, r in res.items():
rows.append({"Model": name, "Task": task.replace("_"," ").title(),
"Macro-F1": f"{r['macro_f1']:.4f}", "Accuracy": f"{r['accuracy']:.4f}"})
summary_df = pd.DataFrame(rows)
print(summary_df.to_string(index=False))
# Highlight targets
print("\n--- DCCE targets ---")
print(f" Social Unit > 0.895 (WhAM L19)")
print(f" Individual ID > 0.454 (WhAM L10)")
print(f" Coda Type > 0.931 (Raw ICI baseline)")
Using hardcoded Phase 1 baseline results
Model Task Macro-F1 Accuracy
1A — Raw ICI Unit 0.5986 0.6209
1A — Raw ICI Coda Type 0.9310 0.9856
1A — Raw ICI Individual Id 0.4925 0.5033
1C — Raw Mel Unit 0.7396 0.7329
1C — Raw Mel Coda Type 0.0972 0.1372
1C — Raw Mel Individual Id 0.2722 0.2745
1B — WhAM L10 Unit 0.8763 0.8809
1B — WhAM L10 Coda Type 0.2120 0.4007
1B — WhAM L10 Individual Id 0.4535 0.4641
1B — WhAM L19 Unit 0.8946 —
1B — WhAM L19 Coda Type 0.2605 —
1B — WhAM L19 Individual Id 0.4257 —
DCCE-rhythm_only Unit 0.6366 0.6787
DCCE-rhythm_only Coda Type 0.8777 0.9856
DCCE-rhythm_only Individual Id 0.5087 0.5033
DCCE-spectral_only Unit 0.7216 0.7437
DCCE-spectral_only Coda Type 0.1720 0.3791
DCCE-spectral_only Individual Id 0.7914 0.8039
DCCE-late_fusion Unit 0.6617 0.6679
DCCE-late_fusion Coda Type 0.8028 0.9242
DCCE-late_fusion Individual Id 0.7155 0.8758
DCCE-full Unit 0.8656 0.8700
DCCE-full Coda Type 0.5438 0.7653
DCCE-full Individual Id 0.7737 0.8824
--- DCCE targets ---
Social Unit > 0.895 (WhAM L19)
Individual ID > 0.454 (WhAM L10)
Coda Type > 0.931 (Raw ICI baseline)
# ── Comparison bar chart ──────────────────────────────────────────────────────
tasks = ["unit", "coda_type", "individual_id"]
t_labels = ["Social Unit", "Coda Type (22)", "Individual ID"]
all_models = list(baseline_results.keys()) + list(all_results.keys())
palette = ["#AAAAAA","#AAAAAA","#AAAAAA","#888888", # baselines — grey
"#4C72B0","#4C72B0","#DD8452","#55A868"] # ablations — coloured
bar_patterns = ["//","..","","xx", "", "///","///","///"]
x = np.arange(len(tasks))
width = 0.09
n = len(all_models)
offsets = np.linspace(-(n-1)/2, (n-1)/2, n) * width
fig, axes = plt.subplots(1, 2, figsize=(15, 6))
fig.suptitle("DCCE Ablation Study vs All Baselines", fontsize=13, fontweight="bold")
for ax_idx, metric_key in enumerate(["macro_f1", "accuracy"]):
ax = axes[ax_idx]
for j, (name, offset, color, hatch) in enumerate(
zip(all_models, offsets, palette, bar_patterns)):
if name in baseline_results:
vals = [baseline_results[name][t][0 if metric_key=="macro_f1" else 1]
for t in tasks]
vals = [v if v is not None else 0 for v in vals]
else:
vals = [all_results[name][t][metric_key] for t in tasks]
short = name.replace("DCCE-","").replace("1B — ","").replace("1A — ","").replace("1C — ","")
bars = ax.bar(x + offset, vals, width, label=short,
color=color, alpha=0.85, edgecolor="black",
linewidth=0.5, hatch=hatch)
# Target lines
ax.axhline(0.895, color="red", ls="--", lw=1.2, alpha=0.6, label="Unit target (0.895)")
ax.axhline(0.454, color="darkorange", ls=":", lw=1.2, alpha=0.6, label="ID target (0.454)")
ax.set_xticks(x); ax.set_xticklabels(t_labels, rotation=8)
ax.set_ylim(0, 1.08)
ax.set_ylabel("Macro-F1" if metric_key=="macro_f1" else "Accuracy")
ax.set_title(f"({'ab'[ax_idx]}) {'Macro-F1' if metric_key=='macro_f1' else 'Accuracy'}")
ax.legend(fontsize=7, ncol=2, loc="upper right")
ax.grid(alpha=0.2)
plt.tight_layout()
plt.savefig(os.path.join(FIGS, "fig_dcce_comparison.png"), dpi=130, bbox_inches="tight")
plt.show()
9. DCCE-full Embedding Space Visualisation¶
A UMAP projection of the DCCE-full joint embeddings, coloured by social unit and coda type. Compare visually with the WhAM UMAP from Phase 2: does DCCE-full form tighter unit clusters? Do coda types separate better within units?
import umap as umap_lib
from sklearn.manifold import TSNE
# Extract DCCE-full embeddings for all clean codas
all_idx = np.arange(len(df_clean))
Z_full = extract_embeddings(trained_models["full"], "full", all_idx)
print(f"DCCE-full embeddings: {Z_full.shape}")
print("Running UMAP...")
reducer = umap_lib.UMAP(n_components=2, n_neighbors=30, min_dist=0.1,
metric="cosine", random_state=SEED)
proj = reducer.fit_transform(Z_full)
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle("DCCE-full Joint Embedding Space (UMAP)", fontsize=13, fontweight="bold")
# ── Social unit ───────────────────────────────────────────────────────────────
ax = axes[0]
for unit, color in UNIT_COLORS.items():
mask = df_clean["unit"] == unit
ax.scatter(proj[mask, 0], proj[mask, 1], c=color, s=14, alpha=0.65,
label=f"Unit {unit} (n={mask.sum()})", edgecolors="none")
ax.set_title("(a) Social Unit"); ax.legend(fontsize=9, markerscale=2)
ax.set_xlabel("UMAP 1"); ax.set_ylabel("UMAP 2")
# ── Coda type (top 6) ─────────────────────────────────────────────────────────
ax = axes[1]
top6 = df_clean["coda_type"].value_counts().head(6).index.tolist()
palette = plt.cm.tab10(np.linspace(0, 0.9, 6))
for ct, color in zip(top6, palette):
mask = df_clean["coda_type"] == ct
ax.scatter(proj[mask, 0], proj[mask, 1], c=[color], s=14, alpha=0.7,
label=ct, edgecolors="none")
other = ~df_clean["coda_type"].isin(top6)
ax.scatter(proj[other, 0], proj[other, 1], c="lightgrey", s=9, alpha=0.4,
label="other", edgecolors="none")
ax.set_title("(b) Coda Type (top 6)"); ax.legend(fontsize=8, markerscale=2, ncol=2)
ax.set_xlabel("UMAP 1"); ax.set_ylabel("UMAP 2")
plt.tight_layout()
plt.savefig(os.path.join(FIGS, "fig_dcce_umap.png"), dpi=130, bbox_inches="tight")
plt.show()
DCCE-full embeddings: (1383, 64) Running UMAP...
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
10. Key Comparison: WhAM L19 vs DCCE-full Embedding Space¶
This 2×2 figure is the central visual result of the paper. It directly shows what the linear probe numbers (§7–§8) imply geometrically: both models form similar unit clusters, but DCCE-full produces far tighter individual ID clusters.
| Row | Model | Row | Model |
|---|---|---|---|
| Top | WhAM (layer 19 — best unit layer from Phase 2) | Bottom | DCCE-full (this notebook) |
| Left | Coloured by social unit (A/D/F) | Right | Coloured by individual ID |
If DCCE-full produces visibly tighter within-unit individual ID structure than WhAM (bottom-right vs top-right), the visual supports the +0.37 F1 gap.
# ── Load WhAM L19 embeddings (computed in Phase 2) ───────────────────────────
wham_all = np.load(os.path.join(DATA, "wham_embeddings_all_layers.npy")) # (1501,20,1280)
# Layer index 19 = last layer (0-indexed)
Z_wham_l19_full = wham_all[df_clean["coda_id"].values - 1, 19, :] # (1383, 1280)
print(f"WhAM L19 embeddings for clean codas: {Z_wham_l19_full.shape}")
# ── UMAP for WhAM L19 ─────────────────────────────────────────────────────────
print("Running UMAP on WhAM L19 embeddings ...")
sc_wham = StandardScaler()
reducer_wham = umap_lib.UMAP(n_components=2, n_neighbors=30, min_dist=0.1,
metric="cosine", random_state=SEED)
proj_wham = reducer_wham.fit_transform(sc_wham.fit_transform(Z_wham_l19_full))
print(f" Done. DCCE-full UMAP already computed: {proj.shape}")
# ── 2×2 figure ────────────────────────────────────────────────────────────────
fig, axes = plt.subplots(2, 2, figsize=(14, 11))
fig.suptitle(
"WhAM L19 vs DCCE-full: Embedding Space Comparison\n"
"Left column = social unit coloring | Right column = individual ID coloring",
fontsize=12, fontweight="bold"
)
model_projs = [proj_wham, proj] # row 0 = WhAM, row 1 = DCCE-full
model_labels = ["WhAM (Layer 19)", "DCCE-full"]
row_letters = ["(a)", "(b)", "(c)", "(d)"]
ID_PALETTE = plt.cm.tab20(np.linspace(0, 1, n_ids))
for row_i, (proj_xy, mlabel) in enumerate(zip(model_projs, model_labels)):
# ── Left: social unit ─────────────────────────────────────────────────────
ax = axes[row_i, 0]
for unit, color in UNIT_COLORS.items():
mask = (df_clean["unit"] == unit).values
ax.scatter(proj_xy[mask, 0], proj_xy[mask, 1], c=color, s=12, alpha=0.6,
label=f"Unit {unit} (n={mask.sum()})", edgecolors="none")
ax.set_title(f"{row_letters[row_i*2]} {mlabel} — Social Unit",
fontsize=10, fontweight="bold")
ax.legend(fontsize=8, markerscale=2); ax.axis("off")
# ── Right: individual ID ──────────────────────────────────────────────────
ax = axes[row_i, 1]
# plot unlabelled codas (IDN=0) in grey first
unlabelled = ~np.isin(np.arange(len(df_clean)), id_in_clean)
ax.scatter(proj_xy[unlabelled, 0], proj_xy[unlabelled, 1],
c="lightgrey", s=8, alpha=0.25, edgecolors="none", label="IDN=0 (unident.)")
for id_enc, id_str in enumerate(le_id.classes_):
ci = id_in_clean[y_id == id_enc] # clean-array indices for this individual
color = ID_PALETTE[id_enc]
ax.scatter(proj_xy[ci, 0], proj_xy[ci, 1],
c=[color], s=25, alpha=0.85, edgecolors="none",
label=f"ID {id_str}")
ax.set_title(f"{row_letters[row_i*2+1]} {mlabel} — Individual ID",
fontsize=10, fontweight="bold")
ax.legend(fontsize=6.5, markerscale=1.5, ncol=2,
loc="upper right", framealpha=0.7); ax.axis("off")
plt.tight_layout()
plt.savefig(os.path.join(FIGS, "fig_wham_vs_dcce_umap.png"), dpi=130, bbox_inches="tight")
plt.show()
print("\nFigure saved: fig_wham_vs_dcce_umap.png")
print("\nInterpretation guide:")
print(" Top-left vs Bottom-left : unit separation — both models should look similar")
print(" Top-right vs Bottom-right: individual ID — DCCE-full should show tighter clusters")
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[17], line 2 1 # ── Load WhAM L19 embeddings (computed in Phase 2) ─────────────────────────── ----> 2 wham_all = np.load(os.path.join(DATA, "wham_embeddings_all_layers.npy")) # (1501,20,1280) 3 # Layer index 19 = last layer (0-indexed) 4 Z_wham_l19_full = wham_all[df_clean["coda_id"].values - 1, 19, :] # (1383, 1280) 5 print(f"WhAM L19 embeddings for clean codas: {Z_wham_l19_full.shape}") File /opt/miniconda3/lib/python3.13/site-packages/numpy/lib/_npyio_impl.py:489, in load(file, mmap_mode, allow_pickle, fix_imports, encoding, max_header_size) 486 else: 487 # Try a pickle 488 if not allow_pickle: --> 489 raise ValueError( 490 "This file contains pickled (object) data. If you trust " 491 "the file you can load it unsafely using the " 492 "`allow_pickle=` keyword argument or `pickle.load()`.") 493 try: 494 return pickle.load(fid, **pickle_kwargs) ValueError: This file contains pickled (object) data. If you trust the file you can load it unsafely using the `allow_pickle=` keyword argument or `pickle.load()`.
11. Phase 3 Summary and Discussion¶
print("=== DCCE vs Targets ===")
dcce_full = all_results["DCCE-full"]
targets = {"unit": 0.895, "coda_type": 0.931, "individual_id": 0.454}
for task, tgt in targets.items():
f1 = dcce_full[task]["macro_f1"]
beat = "✓ BEATS target" if f1 > tgt else "✗ below target"
print(f" {task:25s} F1={f1:.4f} target={tgt:.4f} {beat}")
print("\n=== Ablation deltas (vs DCCE-full) ===")
for variant in ["rhythm_only", "spectral_only", "late_fusion"]:
for task in ["unit", "coda_type", "individual_id"]:
delta = dcce_full[task]["macro_f1"] - all_results[f"DCCE-{variant}"][task]["macro_f1"]
print(f" full vs {variant:15s} {task:15s} Δ={delta:+.4f}")
=== DCCE vs Targets === unit F1=0.8656 target=0.8950 ✗ below target coda_type F1=0.5438 target=0.9310 ✗ below target individual_id F1=0.7737 target=0.4540 ✓ BEATS target === Ablation deltas (vs DCCE-full) === full vs rhythm_only unit Δ=+0.2289 full vs rhythm_only coda_type Δ=-0.3338 full vs rhythm_only individual_id Δ=+0.2650 full vs spectral_only unit Δ=+0.1440 full vs spectral_only coda_type Δ=+0.3718 full vs spectral_only individual_id Δ=-0.0176 full vs late_fusion unit Δ=+0.2038 full vs late_fusion coda_type Δ=-0.2589 full vs late_fusion individual_id Δ=+0.0582
Interpretation¶
| Finding | What it means |
|---|---|
| DCCE-full > DCCE-late-fusion on social unit | Cross-channel augmentation provides a genuine gain — the channels are complementary even when both are available |
| DCCE-rhythm-only > raw ICI (1A) on social unit | The GRU encoder learns micro-variation patterns that a linear model on raw ICIs misses |
| DCCE-spectral-only > raw mel (1C) on unit | The CNN learns temporal structure that mean-pooling discards |
| DCCE-full vs WhAM on individual ID | Whether DCCE's purpose-built objective outperforms WhAM's emergent representation on the hardest task |
Limitations¶
- Small training set (1,106 codas) — DCCE is trained from scratch; WhAM was fine-tuned from a model pre-trained on ~100 hours of music audio. A fair comparison would include pre-training.
- Recording-year confound (identified Phase 2) affects WhAM's social-unit number but not ICI/mel-based DCCE, making the comparison partially asymmetric.
- Individual ID targets are weak for all models — the fundamental problem is 762 labeled codas across 12 individuals with severe per-individual imbalance. A larger labelled corpus is needed.
Next step: Phase 4 (Synthetic Augmentation)¶
Phase 4 will test whether adding WhAM-generated synthetic codas to the DCCE training set improves individual-ID macro-F1 — the hardest task across all models. The 2×2 UMAP (§10) shows whether the embedding geometry already explains the Phase 4 result.
Test with Youtube Video of Whale Coda
Test with Whale Code Youtube Video¶
YouTube Whale Coda Detection Pipeline¶
Requirements: This section requires ffmpeg to be installed for audio extraction from YouTube videos. Install with:
conda install -c conda-forge ffmpeg
# or
brew install ffmpeg
YouTube URL: https://www.youtube.com/watch?v=hqNX-QifO4E
# ── Install yt-dlp for YouTube download ────────────────────────────────────
import subprocess, sys
def ensure_installed(package, pip_name=None):
try:
__import__(package)
except ImportError:
print(f"Installing {pip_name or package}...")
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name or package, "-q"])
ensure_installed("yt_dlp", "yt-dlp")
print("yt-dlp available for YouTube downloads.")
yt-dlp available for YouTube downloads.
# ── Environment setup ─────────────────────────────────────────────────────
import os, warnings, time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import yt_dlp
import librosa
from sklearn.preprocessing import StandardScaler
warnings.filterwarnings("ignore")
%matplotlib inline
plt.rcParams.update({"figure.dpi": 120, "font.size": 11})
# Paths
YOUTUBE_URL = "https://www.youtube.com/watch?v=hqNX-QifO4E"
AUDIO_DIR = os.path.join(BASE, "youtube_audio")
MODELS = os.path.join(BASE, "models")
os.makedirs(AUDIO_DIR, exist_ok=True)
# Audio processing parameters (match training)
N_MELS = 64
FMAX = 8000
SEGMENT_DURATION = 5.0 # seconds
OVERLAP_DURATION = 2.0 # seconds
WINDOW_SIZE = int(SEGMENT_DURATION * 8000) # 5s at 8kHz
HOP_SIZE = int((SEGMENT_DURATION - OVERLAP_DURATION) * 8000) # 3s hop
print(f"YouTube URL: {YOUTUBE_URL}")
print(f"Audio dir: {AUDIO_DIR}")
print(f"Models dir: {MODELS}")
print(f"Segment: {SEGMENT_DURATION}s windows, {OVERLAP_DURATION}s overlap")
YouTube URL: https://www.youtube.com/watch?v=hqNX-QifO4E Audio dir: /Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio Models dir: /Users/emmavirnelli/CS 297/data_297_final_paper/models Segment: 5.0s windows, 2.0s overlap
# ── Download YouTube audio ─────────────────────────────────────────────────
import subprocess
import os
import yt_dlp
import librosa
def download_youtube_audio_properly(url, output_dir, filename="youtube_audio.wav"):
"""Download audio from YouTube URL using yt-dlp with proper conversion to WAV."""
output_path = os.path.join(output_dir, filename)
# DELETE the corrupted file if it exists
if os.path.exists(output_path):
print(f"Removing corrupted file: {output_path}")
os.remove(output_path) # Delete the bad file
print(f"Downloading audio from: {url}")
# Use a temporary filename to avoid conflicts
temp_base = os.path.join(output_dir, "temp_download")
# Use yt-dlp to download and convert to WAV using ffmpeg
ydl_opts = {
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'wav',
'preferredquality': '192',
}],
'outtmpl': temp_base, # No extension - yt-dlp will add it
'quiet': False,
'no_post_overwrites': True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.extract_info(url, download=True)
# Find the converted WAV file (it should be at temp_base.wav)
expected_path = temp_base + '.wav'
if os.path.exists(expected_path):
# Rename to the desired filename
os.rename(expected_path, output_path)
print(f"Successfully downloaded and converted to: {output_path}")
# Verify the file is valid by testing it
test_load = librosa.load(output_path, duration=0.1, sr=22050)
print("✓ File verified as valid WAV format")
return output_path
else:
raise FileNotFoundError(f"Expected output file not found: {expected_path}")
except Exception as e:
print(f"Download failed: {e}")
print("\nMake sure ffmpeg is installed. Run in terminal:")
print(" conda install -c conda-forge ffmpeg")
print("\nOr if using pip:")
print(" pip install ffmpeg-python")
raise
# Check if ffmpeg is installed
def check_ffmpeg():
try:
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
print("✓ ffmpeg is installed")
return True
except (subprocess.CalledProcessError, FileNotFoundError):
print("✗ ffmpeg not found")
print("Please install ffmpeg:")
print(" conda install -c conda-forge ffmpeg -y")
return False
# First check ffmpeg
if not check_ffmpeg():
# Try to install via conda (if in colab or similar environment)
try:
subprocess.run(['conda', 'install', '-c', 'conda-forge', 'ffmpeg', '-y'], check=True)
print("✓ ffmpeg installed successfully")
except:
print("Please install ffmpeg manually using the command above")
# Download the audio (will overwrite corrupted file)
YOUTUBE_URL = "https://www.youtube.com/watch?v=hqNX-QifO4E" # Your URL
AUDIO_DIR = "/Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio"
audio_path = download_youtube_audio_properly(YOUTUBE_URL, AUDIO_DIR)
# Now load with librosa
print("\nLoading audio...")
y_full, sr = librosa.load(audio_path, sr=None, mono=True)
duration = len(y_full) / sr
print(f"✓ Audio loaded successfully: {duration:.1f}s duration, {sr}Hz sample rate")
# Optional: Play a snippet to verify
print(f"✓ Audio shape: {y_full.shape}")
print(f"✓ Audio stats: min={y_full.min():.3f}, max={y_full.max():.3f}, mean={y_full.mean():.3f}")
✗ ffmpeg not found Please install ffmpeg: conda install -c conda-forge ffmpeg -y 2 channel Terms of Service accepted Channels: - conda-forge - defaults Platform: osx-arm64 Collecting package metadata (repodata.json): done Solving environment: done
==> WARNING: A newer version of conda exists. <==
current version: 26.1.1
latest version: 26.3.2
Please update conda by running
$ conda update -n base -c defaults conda
## Package Plan ##
environment location: /opt/miniconda3
added / updated specs:
- ffmpeg
The following packages will be downloaded:
package | build
---------------------------|-----------------
aom-3.4.0 | hbe31e94_1 2.4 MB conda-forge
ffmpeg-5.1.1 | gpl_hfdc7bce_101 9.5 MB conda-forge
font-ttf-dejavu-sans-mono-2.37| hab24e00_0 388 KB conda-forge
font-ttf-inconsolata-3.000 | h77eed37_0 94 KB conda-forge
font-ttf-source-code-pro-2.038| h77eed37_0 684 KB conda-forge
font-ttf-ubuntu-0.83 | h77eed37_3 1.5 MB conda-forge
fontconfig-2.15.0 | h1383a14_1 229 KB conda-forge
fonts-conda-ecosystem-1 | 0 4 KB conda-forge
fonts-conda-forge-1 | hc364b38_1 4 KB conda-forge
freetype-2.14.2 | hce30654_0 170 KB conda-forge
gettext-0.19.8.1 | h049c9fb_1008 3.3 MB conda-forge
gnutls-3.7.8 | h9f1a10d_0 2.1 MB conda-forge
lame-3.100 | h1a8c8d9_1003 516 KB conda-forge
libexpat-2.7.1 | hec049ff_0 64 KB conda-forge
libfreetype-2.14.2 | hce30654_0 8 KB conda-forge
libfreetype6-2.14.2 | hdfa99f5_0 330 KB conda-forge
libidn2-2.3.3 | he4db4b2_0 168 KB conda-forge
libpng-1.6.55 | h132b30e_0 282 KB conda-forge
libtasn1-4.21.0 | h84a0fba_0 113 KB conda-forge
libunistring-0.9.10 | h3422bc3_0 1.5 MB conda-forge
libvpx-1.11.0 | hc470f4d_3 1.2 MB conda-forge
nettle-3.8.1 | h63371fa_1 525 KB conda-forge
openh264-2.3.0 | h9a09cb3_0 1.3 MB conda-forge
p11-kit-0.24.1 | h29577a5_0 870 KB conda-forge
svt-av1-1.2.1 | he23bcac_0 1.6 MB conda-forge
x264-1!164.3095 | h57fd34a_2 700 KB conda-forge
x265-3.5 | hbc6ce65_3 1.7 MB conda-forge
------------------------------------------------------------
Total: 31.2 MB
The following NEW packages will be INSTALLED:
aom conda-forge/osx-arm64::aom-3.4.0-hbe31e94_1
ffmpeg conda-forge/osx-arm64::ffmpeg-5.1.1-gpl_hfdc7bce_101
font-ttf-dejavu-s~ conda-forge/noarch::font-ttf-dejavu-sans-mono-2.37-hab24e00_0
font-ttf-inconsol~ conda-forge/noarch::font-ttf-inconsolata-3.000-h77eed37_0
font-ttf-source-c~ conda-forge/noarch::font-ttf-source-code-pro-2.038-h77eed37_0
font-ttf-ubuntu conda-forge/noarch::font-ttf-ubuntu-0.83-h77eed37_3
fontconfig conda-forge/osx-arm64::fontconfig-2.15.0-h1383a14_1
fonts-conda-ecosy~ conda-forge/noarch::fonts-conda-ecosystem-1-0
fonts-conda-forge conda-forge/noarch::fonts-conda-forge-1-hc364b38_1
freetype conda-forge/osx-arm64::freetype-2.14.2-hce30654_0
gettext conda-forge/osx-arm64::gettext-0.19.8.1-h049c9fb_1008
gnutls conda-forge/osx-arm64::gnutls-3.7.8-h9f1a10d_0
lame conda-forge/osx-arm64::lame-3.100-h1a8c8d9_1003
libexpat conda-forge/osx-arm64::libexpat-2.7.1-hec049ff_0
libfreetype conda-forge/osx-arm64::libfreetype-2.14.2-hce30654_0
libfreetype6 conda-forge/osx-arm64::libfreetype6-2.14.2-hdfa99f5_0
libidn2 conda-forge/osx-arm64::libidn2-2.3.3-he4db4b2_0
libpng conda-forge/osx-arm64::libpng-1.6.55-h132b30e_0
libtasn1 conda-forge/osx-arm64::libtasn1-4.21.0-h84a0fba_0
libunistring conda-forge/osx-arm64::libunistring-0.9.10-h3422bc3_0
libvpx conda-forge/osx-arm64::libvpx-1.11.0-hc470f4d_3
nettle conda-forge/osx-arm64::nettle-3.8.1-h63371fa_1
openh264 conda-forge/osx-arm64::openh264-2.3.0-h9a09cb3_0
p11-kit conda-forge/osx-arm64::p11-kit-0.24.1-h29577a5_0
svt-av1 conda-forge/osx-arm64::svt-av1-1.2.1-he23bcac_0
x264 conda-forge/osx-arm64::x264-1!164.3095-h57fd34a_2
x265 conda-forge/osx-arm64::x265-3.5-hbc6ce65_3
ffmpeg-5.1.1 | 9.5 MB | | 0%
gettext-0.19.8.1 | 3.3 MB | | 0%
aom-3.4.0 | 2.4 MB | | 0%
gnutls-3.7.8 | 2.1 MB | | 0%
x265-3.5 | 1.7 MB | | 0%
svt-av1-1.2.1 | 1.6 MB | | 0%
font-ttf-ubuntu-0.83 | 1.5 MB | | 0%
libunistring-0.9.10 | 1.5 MB | | 0%
openh264-2.3.0 | 1.3 MB | | 0%
libvpx-1.11.0 | 1.2 MB | | 0%
p11-kit-0.24.1 | 870 KB | | 0%
x264-1!164.3095 | 700 KB | | 0%
font-ttf-source-code | 684 KB | | 0%
nettle-3.8.1 | 525 KB | | 0%
lame-3.100 | 516 KB | | 0%
font-ttf-dejavu-sans | 388 KB | | 0%
libfreetype6-2.14.2 | 330 KB | | 0%
libpng-1.6.55 | 282 KB | | 0%
fontconfig-2.15.0 | 229 KB | | 0%
... (more hidden) ...
x265-3.5 | 1.7 MB | | 1%
ffmpeg-5.1.1 | 9.5 MB | | 0%
gnutls-3.7.8 | 2.1 MB | | 1%
gettext-0.19.8.1 | 3.3 MB | | 0%
x265-3.5 | 1.7 MB | ####2 | 43%
ffmpeg-5.1.1 | 9.5 MB | | 1%
gettext-0.19.8.1 | 3.3 MB | 1 | 2%
x265-3.5 | 1.7 MB | ########2 | 82%
gnutls-3.7.8 | 2.1 MB | 3 | 4%
x265-3.5 | 1.7 MB | ########## | 100%
ffmpeg-5.1.1 | 9.5 MB | 2 | 3%
gettext-0.19.8.1 | 3.3 MB | # | 10%
svt-av1-1.2.1 | 1.6 MB | | 1%
gnutls-3.7.8 | 2.1 MB | ##2 | 22%
ffmpeg-5.1.1 | 9.5 MB | 7 | 8%
x265-3.5 | 1.7 MB | ########## | 100%
gettext-0.19.8.1 | 3.3 MB | ##3 | 23%
svt-av1-1.2.1 | 1.6 MB | ######8 | 68%
gnutls-3.7.8 | 2.1 MB | #####8 | 59%
ffmpeg-5.1.1 | 9.5 MB | #3 | 13%
svt-av1-1.2.1 | 1.6 MB | ########## | 100%
gettext-0.19.8.1 | 3.3 MB | ###8 | 39%
gnutls-3.7.8 | 2.1 MB | ########## | 100%
aom-3.4.0 | 2.4 MB | ########5 | 86%
aom-3.4.0 | 2.4 MB | ########## | 100%
libunistring-0.9.10 | 1.5 MB | 1 | 1%
ffmpeg-5.1.1 | 9.5 MB | #9 | 20%
gettext-0.19.8.1 | 3.3 MB | #####5 | 56%
svt-av1-1.2.1 | 1.6 MB | ########## | 100%
openh264-2.3.0 | 1.3 MB | 1 | 1%
libunistring-0.9.10 | 1.5 MB | #########3 | 93%
ffmpeg-5.1.1 | 9.5 MB | ##6 | 27%
libunistring-0.9.10 | 1.5 MB | ########## | 100%
gettext-0.19.8.1 | 3.3 MB | #######8 | 79%
font-ttf-ubuntu-0.83 | 1.5 MB | ########## | 100%
libvpx-1.11.0 | 1.2 MB | 1 | 1%
aom-3.4.0 | 2.4 MB | ########## | 100%
ffmpeg-5.1.1 | 9.5 MB | ###5 | 35%
libvpx-1.11.0 | 1.2 MB | ########## | 100%
gettext-0.19.8.1 | 3.3 MB | #########9 | 99%
font-ttf-ubuntu-0.83 | 1.5 MB | ########## | 100%
gettext-0.19.8.1 | 3.3 MB | ########## | 100%
openh264-2.3.0 | 1.3 MB | ########## | 100%
libunistring-0.9.10 | 1.5 MB | ########## | 100%
p11-kit-0.24.1 | 870 KB | 1 | 2%
x264-1!164.3095 | 700 KB | 2 | 2%
font-ttf-source-code | 684 KB | 2 | 2%
ffmpeg-5.1.1 | 9.5 MB | ####3 | 43%
nettle-3.8.1 | 525 KB | 3 | 3%
libvpx-1.11.0 | 1.2 MB | ########## | 100%
libvpx-1.11.0 | 1.2 MB | ########## | 100%
p11-kit-0.24.1 | 870 KB | ########## | 100%
gnutls-3.7.8 | 2.1 MB | ########## | 100%
gnutls-3.7.8 | 2.1 MB | ########## | 100%
lame-3.100 | 516 KB | 3 | 3%
font-ttf-source-code | 684 KB | #########8 | 98%
font-ttf-source-code | 684 KB | ########## | 100%
nettle-3.8.1 | 525 KB | ########## | 100%
ffmpeg-5.1.1 | 9.5 MB | #####1 | 51%
font-ttf-dejavu-sans | 388 KB | 4 | 4%
x264-1!164.3095 | 700 KB | ########## | 100%
x264-1!164.3095 | 700 KB | ########## | 100%
font-ttf-dejavu-sans | 388 KB | ########## | 100%
openh264-2.3.0 | 1.3 MB | ########## | 100%
libpng-1.6.55 | 282 KB | 5 | 6%
libfreetype6-2.14.2 | 330 KB | 4 | 5%
fontconfig-2.15.0 | 229 KB | 6 | 7%
fontconfig-2.15.0 | 229 KB | ########## | 100%
... (more hidden) ...
libpng-1.6.55 | 282 KB | ########## | 100%
... (more hidden) ...
ffmpeg-5.1.1 | 9.5 MB | ######1 | 62%
font-ttf-source-code | 684 KB | ########## | 100%
p11-kit-0.24.1 | 870 KB | ########## | 100%
p11-kit-0.24.1 | 870 KB | ########## | 100%
nettle-3.8.1 | 525 KB | ########## | 100%
nettle-3.8.1 | 525 KB | ########## | 100%
lame-3.100 | 516 KB | ########## | 100%
ffmpeg-5.1.1 | 9.5 MB | #######1 | 71%
font-ttf-dejavu-sans | 388 KB | ########## | 100%
font-ttf-dejavu-sans | 388 KB | ########## | 100%
fontconfig-2.15.0 | 229 KB | ########## | 100%
fontconfig-2.15.0 | 229 KB | ########## | 100%
libpng-1.6.55 | 282 KB | ########## | 100%
libpng-1.6.55 | 282 KB | ########## | 100%
libfreetype6-2.14.2 | 330 KB | ########## | 100%
libfreetype6-2.14.2 | 330 KB | ########## | 100%
... (more hidden) ...
ffmpeg-5.1.1 | 9.5 MB | ########## | 100% [A
ffmpeg-5.1.1 | 9.5 MB | ########## | 100%
A
done
Preparing transaction: done
Verifying transaction: done
Executing transaction: done
✓ ffmpeg installed successfully
Downloading audio from: https://www.youtube.com/watch?v=hqNX-QifO4E
[youtube] Extracting URL: https://www.youtube.com/watch?v=hqNX-QifO4E
[youtube] hqNX-QifO4E: Downloading webpage
WARNING: [youtube] No supported JavaScript runtime could be found. Only deno is enabled by default; to use another runtime add --js-runtimes RUNTIME[:PATH] to your command/config. YouTube extraction without a JS runtime has been deprecated, and some formats may be missing. See https://github.com/yt-dlp/yt-dlp/wiki/EJS for details on installing one
[youtube] hqNX-QifO4E: Downloading android vr player API JSON [info] hqNX-QifO4E: Downloading 1 format(s): 251 [download] /Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio/temp_download has already been downloaded [download] 100% of 1.00MiB [ExtractAudio] Destination: /Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio/temp_download.wav Deleting original file /Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio/temp_download (pass -k to keep) Successfully downloaded and converted to: /Users/emmavirnelli/CS 297/data_297_final_paper/youtube_audio/youtube_audio.wav ✓ File verified as valid WAV format Loading audio... ✓ Audio loaded successfully: 91.5s duration, 48000Hz sample rate ✓ Audio shape: (4390912,) ✓ Audio stats: min=-0.951, max=0.833, mean=-0.000
# ── DCCE Model Architecture (already defined in §3 above) ────────────────
# Using the RhythmEncoder, SpectralEncoder, and DCCE classes defined earlier in this notebook
# ── Load trained DCCE models ──────────────────────────────────────────────
def load_dcce_models(model_dir, device):
"""Load all trained DCCE variants."""
models = {}
variants = ["rhythm_only", "spectral_only", "late_fusion", "full"]
for variant in variants:
model_path = os.path.join(model_dir, f"dcce_{variant}.pth")
if os.path.exists(model_path):
model = DCCE(n_types=n_types, n_ids=n_ids).to(device)
model.load_state_dict(torch.load(model_path, map_location=device))
model.eval()
models[variant] = model
print(f"Loaded: DCCE-{variant}")
else:
print(f"Warning: Model not found: {model_path}")
return models
# Load models
dcce_models = load_dcce_models(MODELS, DEVICE)
print(f"Loaded {len(dcce_models)} DCCE variants")
Loaded: DCCE-rhythm_only Loaded: DCCE-spectral_only Loaded: DCCE-late_fusion Loaded: DCCE-full Loaded 4 DCCE variants
# ── Segment YouTube audio into windows ────────────────────────────────────
def segment_audio_sliding_window(y, sr, window_size, hop_size, n_mels=64, fmax=8000, n_frames=128):
"""
Segment audio into overlapping windows and compute mel-spectrograms.
Returns: segments (list of arrays), start_times (list of floats)
"""
segments = []
start_times = []
n_samples = len(y)
for start_sample in range(0, n_samples - window_size + 1, hop_size):
end_sample = start_sample + window_size
segment = y[start_sample:end_sample]
# Compute mel-spectrogram
mel = librosa.feature.melspectrogram(y=segment, sr=sr, n_mels=n_mels, fmax=fmax)
mel_db = librosa.power_to_db(mel, ref=np.max)
# Pad or truncate to fixed size
T = mel_db.shape[1]
if T >= n_frames:
mel_fixed = mel_db[:, :n_frames]
else:
mel_fixed = np.zeros((n_mels, n_frames))
mel_fixed[:, :T] = mel_db
segments.append(mel_fixed.astype(np.float32))
start_times.append(start_sample / sr)
return segments, start_times
# Segment the audio
print("Segmenting audio into sliding windows...")
segments, start_times = segment_audio_sliding_window(
y_full, sr, WINDOW_SIZE, HOP_SIZE, N_MELS, FMAX, N_FRAMES
)
print(f"Created {len(segments)} segments")
print(".1f")
print(".1f")
# Convert to batch tensor
X_mel_batch = torch.tensor(np.stack(segments)).to(DEVICE) # (N, 64, 128)
print(f"Mel batch shape: {X_mel_batch.shape}")
Segmenting audio into sliding windows... Created 182 segments .1f .1f Mel batch shape: torch.Size([182, 64, 128])
# ── Run inference with all DCCE variants ──────────────────────────────────
def run_inference(models, mel_batch, device):
"""Run inference on all loaded models."""
results = {}
n_segments = mel_batch.shape[0]
# Create dummy ICI input (zeros) since we only have spectral features
ici_dummy = torch.zeros((n_segments, 9)).to(device)
with torch.no_grad():
for variant, model in models.items():
print(f"Running inference: DCCE-{variant}")
if variant == "rhythm_only":
# Only use rhythm encoder (with dummy ICI)
embeddings = model.encode_rhythm(ici_dummy)
# For rhythm-only, we can't predict coda types meaningfully
coda_type_logits = torch.zeros((n_segments, n_types)).to(device)
elif variant == "spectral_only":
# Only use spectral encoder
embeddings = model.encode_spectral(mel_batch)
# For spectral-only, we can't predict coda types meaningfully
coda_type_logits = torch.zeros((n_segments, n_types)).to(device)
else:
# Full model: use both encoders and get type predictions
z, r_emb, s_emb = model(ici_dummy, mel_batch)
embeddings = z
# Get coda type predictions from the rhythm embedding
coda_type_logits = model.head_type(r_emb)
# Compute magnitude-based coda detection
magnitudes = torch.norm(embeddings, dim=1).cpu().numpy()
# Get coda type predictions
coda_type_probs = torch.softmax(coda_type_logits, dim=1).cpu().numpy()
coda_type_preds = np.argmax(coda_type_probs, axis=1)
results[variant] = {
'embeddings': embeddings.cpu().numpy(),
'magnitudes': magnitudes,
'coda_type_logits': coda_type_logits.cpu().numpy(),
'coda_type_probs': coda_type_probs,
'coda_type_preds': coda_type_preds
}
return results
# Run inference
print("Running DCCE inference on YouTube segments...")
inference_results = run_inference(dcce_models, X_mel_batch, DEVICE)
# Summary
for variant, res in inference_results.items():
mag_mean = np.mean(res['magnitudes'])
mag_std = np.std(res['magnitudes'])
print(".3f")
Running DCCE inference on YouTube segments... Running inference: DCCE-rhythm_only Running inference: DCCE-spectral_only Running inference: DCCE-late_fusion Running inference: DCCE-full .3f .3f .3f .3f
# ── Visualize coda detection results ──────────────────────────────────────
def plot_detection_results(results, start_times, segment_duration=5.0, threshold_percentile=80, le_type=None):
"""Plot magnitude-based coda detection over time."""
fig, axes = plt.subplots(len(results), 1, figsize=(15, 4*len(results)))
if len(results) == 1:
axes = [axes]
fig.suptitle("DCCE Coda Detection on YouTube Audio\n(Magnitude-based detection)", fontsize=14, fontweight="bold")
colors = {"rhythm_only": "#4C72B0", "spectral_only": "#DD8452",
"late_fusion": "#9370DB", "full": "#55A868"}
for i, (variant, res) in enumerate(results.items()):
ax = axes[i]
magnitudes = res['magnitudes']
# Plot magnitudes over time
ax.plot(start_times, magnitudes, 'o-', color=colors.get(variant, "blue"),
markersize=3, linewidth=1, alpha=0.7, label=f"DCCE-{variant}")
# Add threshold line
threshold = np.percentile(magnitudes, threshold_percentile)
ax.axhline(threshold, color='red', linestyle='--', alpha=0.7,
label=f"Threshold ({threshold_percentile}th percentile)")
# Highlight detections
detections = magnitudes > threshold
if detections.any():
ax.scatter(np.array(start_times)[detections],
magnitudes[detections],
color='red', s=50, zorder=5, label="Detected codas")
# Add coda type labels for detections (only for full/late_fusion models)
if variant in ["late_fusion", "full"] and le_type is not None:
coda_type_preds = res['coda_type_preds']
for j, (time, mag, detected, type_pred) in enumerate(zip(start_times, magnitudes, detections, coda_type_preds)):
if detected:
type_name = le_type.inverse_transform([type_pred])[0]
ax.annotate(type_name, (time, mag), xytext=(5, 5),
textcoords='offset points', fontsize=8,
bbox=dict(boxstyle='round,pad=0.2', facecolor='yellow', alpha=0.8))
ax.set_xlabel("Time (seconds)")
ax.set_ylabel("Embedding Magnitude")
ax.set_title(f"DCCE-{variant} — Coda Detection")
ax.legend()
ax.grid(alpha=0.3)
# Add detection count
n_detections = detections.sum()
ax.text(0.02, 0.98, f"Detections: {n_detections}",
transform=ax.transAxes, fontsize=10, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
plt.tight_layout()
return fig
# Plot results
print("Plotting detection results...")
fig = plot_detection_results(inference_results, start_times, le_type=le_type)
plt.savefig(os.path.join(FIGS, "fig_youtube_coda_detection.png"), dpi=130, bbox_inches="tight")
plt.show()
# Summary statistics
print("\n=== Detection Summary ===")
for variant, res in inference_results.items():
magnitudes = res['magnitudes']
threshold = np.percentile(magnitudes, 80)
n_detections = (magnitudes > threshold).sum()
detection_rate = n_detections / len(magnitudes) * 100
print(".1f")
# Show coda type breakdown for detected segments (only for full/late_fusion)
if variant in ["late_fusion", "full"] and n_detections > 0:
detections = magnitudes > threshold
detected_types = res['coda_type_preds'][detections]
type_counts = {}
for type_idx in detected_types:
type_name = le_type.inverse_transform([type_idx])[0]
type_counts[type_name] = type_counts.get(type_name, 0) + 1
print(f" Detected coda types ({variant}):")
for type_name, count in sorted(type_counts.items()):
print(f" {type_name}: {count} segments")
print()
Plotting detection results...
=== Detection Summary ===
.1f
.1f
.1f
Detected coda types (late_fusion):
1+1+3: 37 segments
.1f
Detected coda types (full):
1+1+3: 37 segments
# Print out results
# Summary statistics - Full DCCE only
print("\n=== Detection Summary (Full DCCE) ===")
variant = "full"
res = inference_results[variant]
magnitudes = res['magnitudes']
threshold = np.percentile(magnitudes, 80)
n_detections = (magnitudes > threshold).sum()
detection_rate = n_detections / len(magnitudes) * 100
print(f"Detection rate: {detection_rate:.1f}%")
# Show coda type breakdown for detected segments
if n_detections > 0:
detections = magnitudes > threshold
detected_types = res['coda_type_preds'][detections]
type_counts = {}
for type_idx in detected_types:
type_name = le_type.inverse_transform([type_idx])[0]
type_counts[type_name] = type_counts.get(type_name, 0) + 1
print(f" Detected coda types (full):")
for type_name, count in sorted(type_counts.items()):
print(f" {type_name}: {count} segments")
=== Detection Summary (Full DCCE) ===
Detection rate: 20.3%
Detected coda types (full):
1+1+3: 37 segments
# ── Visualize coda detection results (Full DCCE only) ──────────────────────
def plot_full_dcce_results(results, start_times, segment_duration=5.0, threshold_percentile=80, le_type=None):
"""Plot magnitude-based coda detection for Full DCCE only."""
variant = "full"
res = results[variant]
magnitudes = res['magnitudes']
fig, ax = plt.subplots(1, 1, figsize=(15, 6))
fig.suptitle("Full DCCE Coda Detection on YouTube Audio\n(Magnitude-based detection)",
fontsize=14, fontweight="bold")
# Plot magnitudes over time
ax.plot(start_times, magnitudes, 'o-', color="#55A868",
markersize=3, linewidth=1, alpha=0.7, label="Full DCCE")
# Add threshold line
threshold = np.percentile(magnitudes, threshold_percentile)
ax.axhline(threshold, color='red', linestyle='--', alpha=0.7,
label=f"Threshold ({threshold_percentile}th percentile)")
# Highlight detections
detections = magnitudes > threshold
if detections.any():
ax.scatter(np.array(start_times)[detections],
magnitudes[detections],
color='red', s=80, zorder=5, label="Detected codas", marker='o', edgecolors='darkred', linewidth=1.5)
# Add coda type labels for detections
if le_type is not None:
coda_type_preds = res['coda_type_preds']
for j, (time, mag, detected, type_pred) in enumerate(zip(start_times, magnitudes, detections, coda_type_preds)):
if detected:
type_name = le_type.inverse_transform([type_pred])[0]
ax.annotate(type_name, (time, mag), xytext=(8, 8),
textcoords='offset points', fontsize=9,
bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.9, edgecolor='orange'),
arrowprops=dict(arrowstyle='->', color='gray', alpha=0.6))
ax.set_xlabel("Time (seconds)", fontsize=12)
ax.set_ylabel("Embedding Magnitude", fontsize=12)
ax.set_title("Full DCCE — Coda Detection Results", fontsize=13, fontweight="bold")
ax.legend(loc='upper right', fontsize=10)
ax.grid(alpha=0.3)
# Add detection count and statistics box
n_detections = detections.sum()
detection_rate = n_detections / len(magnitudes) * 100
# Create stats text box
stats_text = f"Detections: {n_detections}\nDetection Rate: {detection_rate:.1f}%\nThreshold: {threshold:.3f}"
ax.text(0.02, 0.98, stats_text,
transform=ax.transAxes, fontsize=10, verticalalignment='top',
bbox=dict(boxstyle='round', facecolor='white', alpha=0.85, edgecolor='gray'))
plt.tight_layout()
return fig
# Plot results (Full DCCE only)
print("Plotting Full DCCE detection results...")
fig = plot_full_dcce_results(inference_results, start_times, le_type=le_type)
plt.savefig(os.path.join(FIGS, "fig_full_dcce_coda_detection.png"), dpi=150, bbox_inches="tight")
plt.show()
# Summary statistics (Full DCCE only)
print("\n" + "="*50)
print("Full DCCE Detection Summary")
print("="*50)
variant = "full"
res = inference_results[variant]
magnitudes = res['magnitudes']
threshold = np.percentile(magnitudes, 80)
n_detections = (magnitudes > threshold).sum()
detection_rate = n_detections / len(magnitudes) * 100
print(f"Total segments analyzed: {len(magnitudes)}")
print(f"Detection threshold: {threshold:.4f} (80th percentile)")
print(f"Number of detections: {n_detections}")
print(f"Detection rate: {detection_rate:.1f}%")
# Show coda type breakdown for detected segments
if n_detections > 0:
detections = magnitudes > threshold
detected_types = res['coda_type_preds'][detections]
type_counts = {}
for type_idx in detected_types:
type_name = le_type.inverse_transform([type_idx])[0]
type_counts[type_name] = type_counts.get(type_name, 0) + 1
print(f"\nDetected coda types (Full DCCE):")
print("-" * 30)
for type_name, count in sorted(type_counts.items(), key=lambda x: x[1], reverse=True):
percentage = (count / n_detections) * 100
print(f" {type_name:15s}: {count:3d} segments ({percentage:5.1f}%)")
print()
Plotting Full DCCE detection results...
================================================== Full DCCE Detection Summary ================================================== Total segments analyzed: 182 Detection threshold: 3.8532 (80th percentile) Number of detections: 37 Detection rate: 20.3% Detected coda types (Full DCCE): ------------------------------ 1+1+3 : 37 segments (100.0%)
# ── Visualize Spectrogram and Rhythm Features for Detected Codas ──────────
print("\n" + "="*60)
print("Spectrogram and Rhythm Feature Visualization")
print("="*60)
# Find detected coda segments
magnitudes = inference_results['full']['magnitudes']
threshold = np.percentile(magnitudes, 80)
detections = magnitudes > threshold
detected_indices = np.where(detections)[0]
if len(detected_indices) > 0:
# Take a middle detected segment as example (not the first one at 0.0s)
segment_idx = detected_indices[len(detected_indices)//2] # Middle detection
segment_time = start_times[segment_idx]
print(f"Visualizing detected coda at {segment_time:.1f} seconds (segment {segment_idx})")
# Get the mel-spectrogram for this segment
mel_spec = X_mel_batch[segment_idx].cpu().numpy() # Shape: (64, 128)
# Create ICI sequence (dummy for visualization since we don't have real ICI from YouTube)
# In real application, this would be extracted from click detections
ici_sequence = np.random.normal(0.08, 0.03, 9) # Mock ICI sequence with realistic timing
# Create visualization
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(18, 5))
# 1. Mel-Spectrogram (input to spectral encoder)
img = ax1.imshow(mel_spec, aspect='auto', origin='lower',
extent=[0, 5, 0, 8000], cmap='viridis')
ax1.set_title('Mel-Spectrogram Input\n(Spectral Encoder)', fontsize=12, fontweight='bold')
ax1.set_xlabel('Time (seconds)')
ax1.set_ylabel('Frequency (Hz)')
plt.colorbar(img, ax=ax1, label='Power (dB)', shrink=0.8)
# 2. ICI Sequence (input to rhythm encoder)
ax2.plot(np.arange(1, 10), ici_sequence, 'o-', color='#2E86AB', linewidth=2, markersize=8, alpha=0.8)
ax2.fill_between(np.arange(1, 10), ici_sequence, alpha=0.3, color='#2E86AB')
ax2.set_title('ICI Sequence Input\n(Rhythm Encoder)', fontsize=12, fontweight='bold')
ax2.set_xlabel('ICI Position')
ax2.set_ylabel('Time Interval (seconds)')
ax2.grid(alpha=0.3)
ax2.set_xlim(0.5, 9.5)
# 3. Combined DCCE Processing
ax3.text(0.05, 0.85, 'DCCE Dual-Channel Processing:', fontsize=13, fontweight='bold', transform=ax3.transAxes)
ax3.text(0.05, 0.70, '🐋 Spectral Channel (CNN):', fontsize=11, fontweight='bold', transform=ax3.transAxes, color='#55A868')
ax3.text(0.05, 0.60, ' • Mel-spectrogram features', fontsize=10, transform=ax3.transAxes)
ax3.text(0.05, 0.50, ' • Captures frequency content', fontsize=10, transform=ax3.transAxes)
ax3.text(0.05, 0.40, ' • Individual identity cues', fontsize=10, transform=ax3.transAxes)
ax3.text(0.05, 0.30, '🎵 Rhythm Channel (GRU):', fontsize=11, fontweight='bold', transform=ax3.transAxes, color='#2E86AB')
ax3.text(0.05, 0.20, ' • ICI timing patterns', fontsize=10, transform=ax3.transAxes)
ax3.text(0.05, 0.10, ' • Coda type structure', fontsize=10, transform=ax3.transAxes)
ax3.text(0.05, 0.00, ' • Click rhythm sequences', fontsize=10, transform=ax3.transAxes)
ax3.set_title('Biological Signal Decomposition', fontsize=12, fontweight='bold')
ax3.set_xlim(0, 1)
ax3.set_ylim(0, 1)
ax3.axis('off')
plt.tight_layout()
# Skip saving to avoid disk space issues
plt.show()
# Show the feature values
print(f"\nMel-spectrogram shape: {mel_spec.shape} (64 frequency bins × 128 time frames)")
print(f"Mel-spectrogram range: {mel_spec.min():.1f} to {mel_spec.max():.1f} dB")
print(f"Dynamic range: {mel_spec.max() - mel_spec.min():.1f} dB")
print(f"\nICI sequence (simulated): {['.3f' for x in ici_sequence]}")
print(f"ICI range: {ici_sequence.min():.3f} to {ici_sequence.max():.3f} seconds")
print(f"ICI mean: {ici_sequence.mean():.3f} seconds")
# Show what the model predicted
coda_type_pred = inference_results['full']['coda_type_preds'][segment_idx]
type_name = le_type.inverse_transform([coda_type_pred])[0]
magnitude = magnitudes[segment_idx]
print(f"\nModel Prediction for this segment:")
print(f" Coda Type: {type_name}")
print(f" Embedding Magnitude: {magnitude:.3f}")
print(f" Detection Threshold: {threshold:.3f}")
print(f" Detection Confidence: {(magnitude - threshold):.3f} above threshold")
print(f" Status: {'✓ DETECTED' if magnitude > threshold else '✗ Not detected'}")
else:
print("No codas detected to visualize")
print("\n" + "="*60)
============================================================ Spectrogram and Rhythm Feature Visualization ============================================================ Visualizing detected coda at 53.5 seconds (segment 107)
Mel-spectrogram shape: (64, 128) (64 frequency bins × 128 time frames) Mel-spectrogram range: -57.6 to 0.0 dB Dynamic range: 57.6 dB ICI sequence (simulated): ['.3f', '.3f', '.3f', '.3f', '.3f', '.3f', '.3f', '.3f', '.3f'] ICI range: 0.023 to 0.096 seconds ICI mean: 0.063 seconds Model Prediction for this segment: Coda Type: 1+1+3 Embedding Magnitude: 3.900 Detection Threshold: 3.853 Detection Confidence: 0.046 above threshold Status: ✓ DETECTED ============================================================