Day 5 · Self-supervised speech encoders (wav2vec 2.0, HuBERT, WavLM): what each layer knows about who vs what¶
Goal in one line: understand how a speech encoder learns from raw audio with no transcripts, and measure which of its layers carry speaker identity and which carry content.
What you will build: a miniature HuBERT (k-means pseudo-labels + a masked-prediction Transformer) trained on 68 Khmer clips (48 probe clips + 20 demo clips), and src/probe.py, a layer-probing tool. The course then points the tool at the real encoders (WavLM, HuBERT, mHuBERT-147). That last step needs internet and is not run in this file.
Time: ~3 h
- Self-supervised: the model learns from the audio alone. Nobody gives it words.
- Masked prediction: some frames are hidden and the model must guess them from the frames around.
- Layer probe: a simple classifier (logistic regression) trained on one layer's features. If it works, that layer linearly encodes the thing.
- Pseudo-label: a label made by a simple method (here k-means), not by a person.
By the end you can ...
- draw the shared skeleton of wav2vec 2.0 / HuBERT / WavLM and say what each pretext task predicts;
- implement the 7-layer CNN front-end and explain why it gives 50 frames per second;
- turn log-mel frames into k-means pseudo-labels and train a masked-prediction encoder on CPU;
- probe a layer for speaker vs content with held-out splits and read the per-layer curve;
- explain why kNN-VC reads WavLM layer 6, and why an English target pool might break Khmer sounds (the log-mel check here does not show it).
How to read this notebook (for the team)¶
| You see | What it is | Do you need it? |
|---|---|---|
| Purple heading | a new section and its explanation | yes: read the bold lines and the "Why it matters" line |
| Visualization Version | pictures (one is animated) that explain the section | yes: the first image of each section is the fastest way in |
| Additional reading | what the next code cell does and what its output means, plus extra notes | optional: read just the In short lines if you are busy |
| code cell + output | the real computation on 68 clips and two target pools of 22 clips each | run it, look at the result |
Fast path (≈ 40 min): purple text → first Visualization image → "In short" lines. Full path (≈ 3 h): read everything and run every cell top to bottom.
The numbers in the explanations come from the run saved in this file. Seeds are fixed, so a re-run on the same setup should give the same numbers. Timings (seconds) change from machine to machine and are not quoted.
Important, what is real and what is a fallback:
- Sections 1 to 4, 7 and the Build section are real runs on our clips (mini model, k-means, probes, log-mel pools).
- FALLBACK in section 5: WavLM, HuBERT and mHuBERT-147 cannot be downloaded on the machine where this file was built (the cells print
OSError: Can't load feature extractor ...). The cells then print the shipped filedata/meta/ssl_probe_reference.csv. It holds only words (rising / high / falling), no numbers. There are no real-encoder results in this notebook. On a machine with internet the same cells give the real curves.- FALLBACK in section 6: because WavLM is not available, the cell uses the mini model's layer 2 instead of WavLM layer 6. Every number from that cell (100 % and 44 %) belongs to the mini model, not to WavLM. Every code cell ran here, top to bottom, with no errors.
1. The big idea and the three pretext tasks¶
raw wave 16 kHz ──► CNN feature encoder ──► 50 frames/s, 512-d ──► Transformer (12 layers, 768-d) ──► layer 1 ... layer 12
(7 conv layers, (20 ms each) each layer: self-attention + MLP
total stride 320)
pretext task on MASKED frames:
wav2vec 2.0: contrastive, "which of these quantized codes was here?"
HuBERT: "which k-means cluster id was here?"
WavLM: HuBERT + noisy/overlapped input + gated relative position bias
All three models share one skeleton. A small CNN turns 320 samples (20 ms) into one vector, so 1 s of audio becomes 50 frames. A Transformer then mixes the frames across time.
During pre-training some frames are masked (hidden). The model must guess what was there from the frames around. Nobody tells it the words. That is self-supervision: the signal itself is the teacher.
Why this matters for Khmer: labelled Khmer speech is scarce (a few hours of transcribed audio (background fact from the course text, not checked here)), but unlabelled Khmer speech is cheap. A self-supervised encoder learns sound structure from unlabelled audio. A Khmer ASR or voice-conversion system can then start from an encoder that already "hears" sounds (background fact from the course text, not checked here).
The three pretext tasks. Think of a sentence with a word hidden: "I drink ___ every morning." You can guess coffee because the context limits the choice. The speech version hides 20 ms frames. The three models differ only in what counts as a correct guess:
- wav2vec 2.0 turns the CNN output into discrete codes and asks: among the true code and 100 distractors (background fact from the course text, not checked here), which one belongs here? (contrastive loss, $-\log \frac{e^{\mathrm{sim}(c_t, q_t)/\kappa}}{\sum_{\tilde q} e^{\mathrm{sim}(c_t, \tilde q)/\kappa}}$).
- HuBERT runs k-means offline on simple features. Every frame gets a cluster id in $\{1..k\}$. The model asks: which id was here? (plain cross-entropy at masked frames, $-\log p(z_t \mid \text{context})$).
- WavLM keeps the HuBERT loss but makes the input harder. It mixes in noise or a second speaker, and the targets are still the clean speaker's ids. The model must separate "my speaker" from "the other speaker". The course says this is why WavLM is the best of the three on speaker tasks (background fact from the course text, not checked here).
| model | pretext target | what is certain | what to check in the paper |
|---|---|---|---|
| wav2vec 2.0 (Baevski et al., 2020) | contrastive: pick the true quantized latent among distractors; the quantizer is learned jointly (Gumbel softmax, 2 codebooks x 320 codes) | CNN stride 320, masked spans, Transformer | the exact masking probability (p=0.065, span 10) and the diversity loss weight |
| HuBERT (Hsu et al., 2021) | cross-entropy on k-means cluster ids of MFCC (iteration 1) then of its own layer features (iteration 2+) | targets are offline cluster ids, the loss is only on masked frames | k = 100 then 500; which layer is clustered in iteration 2 (layer 6 of the base model) |
| WavLM (Chen et al., 2022) | HuBERT loss, but the input is mixed with noise or another speaker and the model must predict the clean speaker's labels | gated relative position bias in attention; better speaker tasks | the mixing ratio and the 94k-hour training set details |
| mHuBERT-147 (Boito et al., 2024) | HuBERT loss on 147 languages, faiss-based clustering | multilingual, small (95M) | whether Khmer is in the 147 languages (we believe yes, via VoxLingua107/other sources; verify in the language table) |
The table is the course text. All of it is background that we did not check here.
Worked example (arithmetic). A 3 s Khmer clip gives about 150 frames. HuBERT starts masked spans of 10 frames at about 8 % of the positions (background fact from the course text, not checked here). If starts are independent, the share of masked frames is about $1 - 0.92^{10} = 0.566$, so roughly 57 % (computed separately, not printed by the cells). The course says "50-65 %". With k = 500 clusters a random guess costs $\ln 500 \approx 6.2$ nats. The course says a trained HuBERT reaches about 2 nats (background fact from the course text, not checked here). Our mini model uses k = 32 and ends at 2.68 nats (section 3): a different setup, so do not compare the two numbers.
In our research. kNN-VC (the voice-conversion system built on Day 6) changes a voice by reading WavLM features at layer 6. It then replaces every frame by its nearest neighbours in a target speaker's pool. If layer 6 still carries the source speaker, the anonymized voice leaks identity. If layer 6 has lost the content, the words break. So the question "which layer encodes who vs what" is the question "what leaks and what is preserved" for the Khmer speaker-anonymization project.
Why it matters: the pretext task decides what the layers must encode. Predicting cluster ids of sounds should push sound structure into the upper layers. The course adds that speaker identity helps the guess, so it stays in the lower layers (course explanation, not tested here). Sections 4 and 5 look at this.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short load the libraries, set paths and random seeds, import the Day 1 helpers and src/probe.py, and read the clip lists.
from src.utils import ...: the Day 1 helpers (load,play,logmel, ...).from src.probe import ...: the probing tool (src/probe.pyhas six functions; this notebook imports five of them). It already exists in the project; section "Build" uses it again.np.random.seed(0); torch.manual_seed(0)fix the seeds, so the training in section 3 can be repeated.OUT = ../outputs/day5is where the notebook saves its pictures and tables.
What the output means¶
In short probe clips: 48 | speakers: 8 | sentences: 6; demo clips: 20; total audio 272 s; one line with the torch version.
- 68 clips in total = 48 probe + 20 demo clips: the data of the mini model.
- The thread count in the last line depends on the machine and does not matter.
%matplotlib inline
import os, sys, time, warnings
os.environ.setdefault("OMP_NUM_THREADS", "4") # before numpy/torch: keep CPU threads sane
sys.path.insert(0, "..")
warnings.filterwarnings("ignore")
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import librosa
import torch
import torch.nn as nn
import torch.nn.functional as F
from src.utils import load, play, show_wave, show_spec, logmel, median_f0, SR, HOP
from src.probe import pool_features, probe_accuracy, probe_layers, layer_similarity, plot_layer_curves
np.random.seed(0)
torch.manual_seed(0)
torch.set_num_threads(min(4, os.cpu_count() or 1))
DATA = Path("../data")
OUT = Path("../outputs/day5"); OUT.mkdir(parents=True, exist_ok=True)
CACHE = Path.home() / ".cache" # pretrained models (Section 5) are cached here
probe_meta = pd.read_csv(DATA / "meta/probe_clips.csv") # 48 clips = 8 speakers x 6 sentences
demo_meta = pd.read_csv(DATA / "meta/demo_clips.csv") # 20 clips, 10 speakers
print(f"probe clips: {len(probe_meta)} speakers: {probe_meta.speaker.nunique()} sentences: {probe_meta.sentence_id.nunique()}")
print(f"demo clips: {len(demo_meta)} total audio for the mini model: {probe_meta.duration.sum() + demo_meta.duration.sum():.0f} s")
print("torch", torch.__version__, "| threads", torch.get_num_threads())
probe clips: 48 speakers: 8 sentences: 6 demo clips: 20 total audio for the mini model: 272 s torch 2.14.1+cu130 | threads 2
2. The CNN front-end by hand¶
All three models start with the same 7-layer 1-D CNN (kernel sizes 10, 3, 3, 3, 3, 2, 2; strides 5, 2, 2, 2, 2, 2, 2; 512 channels; GELU). Two numbers follow from the strides alone:
- total stride $= 5 \cdot 2^6 = 320$ samples $= 20$ ms at 16 kHz $\Rightarrow$ 50 frames per second;
- receptive field (how many samples one output frame sees). Walk up the layers: $r_l = r_{l-1} + (k_l - 1)\cdot s_1 s_2 \cdots s_{l-1}$. Starting from $r_0 = 1$ this gives 10, 20, 40, 80, 160, 240 and 400 samples = 25 ms (checked by hand and by the cell).
So each frame summarises 25 ms and the frames are 20 ms apart. Compare with our STFT from Day 1: 25 ms window, hop 10 ms, 100 frames per second. The SSL front-end has half the frame rate, and its "filterbank" is learned (random here) instead of fixed mel triangles.
Why it matters: every later number (features per second, pool size, kNN cost) is counted in these 20 ms frames.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short build the 7-layer front-end with random weights, compute its geometry, and run it on a 3 s clip.
ConvFrontEndstacks 7Conv1dlayers with GELU. Real models add GroupNorm / LayerNorm; here only the geometry matters.geometry()walks up the layers and returns the total stride and the receptive field.- The 3 s clip is the first 3 s of
khmer_studio.wav.n_stft = 1 + len(x) // HOPis the number of STFT frames (librosa pads the centre).
What the output means¶
In short total stride = 320 samples = 20 ms → 50 frames/s; receptive field = 400 samples = 25 ms. For a 3 s clip: 149 frames × 512 (CNN) vs 301 frames (STFT). Front-end parameters: 4.20 M.
- 149 comes from $(48000 - 400)/320 + 1 = 149.75$, cut down to 149. There is no padding, so a little is lost at the edge.
- The STFT gives twice as many frames because its hop is 10 ms (301).
- The front-end has 4.2 M parameters. The course says the Transformer on top has about 90 M (background fact from the course text, not checked here). If that is right, almost all the "knowledge" lives in the Transformer layers, and these are the layers we probe.
class ConvFrontEnd(nn.Module):
'''The wav2vec 2.0 / HuBERT / WavLM feature encoder (random weights here; only the geometry matters).'''
def __init__(self, kernels=(10, 3, 3, 3, 3, 2, 2), strides=(5, 2, 2, 2, 2, 2, 2), ch=512):
super().__init__()
layers, cin = [], 1
for k, s in zip(kernels, strides):
layers += [nn.Conv1d(cin, ch, k, stride=s, bias=False), nn.GELU()] # real models add GroupNorm / LayerNorm
cin = ch
self.net = nn.Sequential(*layers)
self.kernels, self.strides = kernels, strides
def forward(self, wav): # wav: (batch, samples)
return self.net(wav[:, None, :]).transpose(1, 2) # -> (batch, frames, 512)
def geometry(self):
stride, rf = 1, 1
for k, s in zip(self.kernels, self.strides):
rf += (k - 1) * stride # each new layer widens the window by (k-1) input-strides
stride *= s
return stride, rf
fe = ConvFrontEnd()
stride, rf = fe.geometry()
print(f"total stride = {stride} samples = {1000*stride/SR:.0f} ms -> {SR/stride:.0f} frames/s")
print(f"receptive field = {rf} samples = {1000*rf/SR:.0f} ms")
x = load(DATA / "audio/khmer_studio.wav")[: 3 * SR] # a 3 s clip
with torch.no_grad():
feats = fe(torch.from_numpy(x)[None])
n_stft = 1 + len(x) // HOP # librosa centre-pads: frames = 1 + samples/hop
print(f"3 s clip: CNN front-end gives {feats.shape[1]} frames x {feats.shape[2]} dims; STFT/log-mel gives {n_stft} frames (hop 10 ms)")
print(f"parameters in the front-end: {sum(p.numel() for p in fe.parameters())/1e6:.2f} M")
total stride = 320 samples = 20 ms -> 50 frames/s receptive field = 400 samples = 25 ms
3 s clip: CNN front-end gives 149 frames x 512 dims; STFT/log-mel gives 301 frames (hop 10 ms) parameters in the front-end: 4.20 M
3. Masked prediction from scratch (a miniature HuBERT)¶
We build the HuBERT idea at a size that trains in a few minutes on a CPU. Three changes keep it small:
| real HuBERT | our mini version |
|---|---|
| CNN front-end on raw wave, 50 frames/s | log-mel frames (80-d, 100 frames/s) from Day 1 |
| k-means on MFCC, k = 100 (iteration 1) | k-means on log-mel, k = 32 |
| 12-layer Transformer, d = 768, 960 h LibriSpeech | 2-layer Transformer, d = 128, 100 windows of 2 s = 200 s of Khmer (from 272 s of clips) |
The left column (real HuBERT) is the course's description (background fact from the course text, not checked here). The right column is what this notebook runs.
The recipe is the same: (a) features, (b) k-means pseudo-labels, (c) mask spans and predict the labels at masked frames, (d) look at what was learned.
What our run shows: the loss falls from 3.62 to 2.68 nats and passes the "always the most common cluster" baseline (2.95). So the model uses context. It does not prove that the model learned sounds that carry over to new audio.
Why it matters: the loss is computed only at hidden frames. If visible frames counted too, the model could copy its input (the k-means id is a function of the input frame).
Additional reading: you can read all of it or just read the "In short"¶
Step (a): features¶
In short 68 clips become 27 270 log-mel frames (about 273 s of frames; the clips add up to 272.4 s of audio).
3a. Log-mel for 68 clips¶
The next cell reads all 68 wave files and turns each into log-mel frames (80 bands, one frame per 10 ms, in dB, loudest bin = 0). It then standardises each mel band over all frames, so that k-means and the Transformer do not only see loudness.
- 68 clips = 48 probe + 20 demo clips.
- The probe clips total 199.2 s and the demo clips 73.2 s (computed separately, not printed by the cells).
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short load the 68 clips, compute their log-mel frames, and standardise each mel band.
probe_wavs,demo_wavs: the waveforms.mels: one(T, 80)array per clip.mels_z: the same, z-scored per mel band.
What the output means¶
In short 68 clips → 27270 log-mel frames; clip 0 has shape (317, 80).
- 27 270 frames × 10 ms = 272.7 s of frames. The clips add up to 272.4 s of audio (199.2 + 73.2 s), printed as 272 s in the first cell (computed separately, not printed by the cells).
- Clip 0 has 317 frames = 3.17 s.
def load_clip(utt_id, folder):
return load(DATA / f"audio/{folder}/{utt_id}.wav")
t0 = time.time()
probe_wavs = [load_clip(u, "probe") for u in probe_meta.utt_id]
demo_wavs = [load_clip(u, "demo") for u in demo_meta.utt_id]
all_wavs = probe_wavs + demo_wavs
mels = [logmel(w).T.astype(np.float32) for w in all_wavs] # each (T, 80), dB, loudest bin = 0
n_frames = sum(m.shape[0] for m in mels)
print(f"{len(mels)} clips -> {n_frames} log-mel frames in {time.time()-t0:.1f} s (e.g. clip 0: {mels[0].shape})")
# standardise per mel band so that k-means / the Transformer do not just see loudness
mel_mean = np.concatenate(mels).mean(0); mel_std = np.concatenate(mels).std(0) + 1e-5
mels_z = [(m - mel_mean) / mel_std for m in mels]
68 clips -> 27270 log-mel frames in 0.5 s (e.g. clip 0: (317, 80))
Additional reading: you can read all of it or just read the "In short"¶
Step (b): pseudo-labels¶
In short k-means turns every 10 ms frame into one of 32 cluster ids. These ids are the targets of the mini model.
3b. k-means pseudo-labels¶
k-means groups the 80-d frames into k = 32 clusters. Nobody tells it which frames are vowels. It only groups frames that look alike. The hope (HuBERT's bet) is that "looks alike in log-mel" is already related to "same sound".
Formula: assign each frame $m_t$ to $z_t = \arg\min_j \|m_t - \mu_j\|^2$. The 32 centroids $\mu_j$ are the only thing learned.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short fit k-means (k = 32) on all frames, label every frame, and find the quiet (silence / noise-floor) clusters.
MiniBatchKMeans(32)is fitted on all 27 270 frames.labels[i]is the cluster id of every frame of clip i.- A cluster is "quiet" if the mean level of its frames is below -62 dB.
SILis the set of quiet clusters. - The last two lines print the loudest clusters and the most common clusters.
What the output means¶
In short k-means: 32 clusters on 27270 frames. 8 quiet clusters: [1, 4, 8, 9, 18, 22, 30, 31], mean level from -62 to -78 dB, together 29 % of all frames. Loudest clusters: 6, 13, 19, 2, 21 (-32 to -43 dB). The 5 most common clusters each hold about 0.05 of the frames.
- Silence is a set of quiet clusters, not one id. The quiet clusters have different levels (-62, -63, -64, -64, -69, -70, -74, -78 dB).
- The course code comment says phone noise sits near -65 dB and clean studio silence near -78 dB. The printed levels fit that pattern, but we did not check which clips fall in which cluster (course explanation, not tested here).
- The most common clusters (30, 10, 27, 31, 22) include three quiet ones (30, 31, 22).
from sklearn.cluster import MiniBatchKMeans
K = 32
t0 = time.time()
km = MiniBatchKMeans(n_clusters=K, random_state=0, batch_size=2048, n_init=3).fit(np.concatenate(mels_z))
labels = [km.predict(m).astype(np.int64) for m in mels_z] # pseudo-label per frame
print(f"k-means: {K} clusters fitted on {n_frames} frames in {time.time()-t0:.1f} s")
# which clusters are silence / background noise? -> those whose frames are quiet on the real dB scale.
# Phone recordings have a noisy floor (around -65 dB) that is NOT the same cluster as clean studio silence (-78 dB),
# so "silence" is a small SET of quiet clusters, not one id.
all_ids = np.concatenate(labels); counts = np.bincount(all_ids, minlength=K)
cluster_db = np.array([np.concatenate(mels)[all_ids == k].mean() for k in range(K)]) # mean dB of each cluster's frames
SIL = set(int(k) for k in np.where(cluster_db < -62)[0]) # quiet clusters = silence / noise floor
is_sil = lambda ids: np.isin(ids, list(SIL))
print(f"quiet (silence/noise) clusters: {sorted(SIL)} mean dB {np.round(cluster_db[sorted(SIL)], 0)} "
f"-> {100*counts[list(SIL)].sum()/n_frames:.0f} % of all frames")
print("loudest clusters:", np.argsort(-cluster_db)[:5], "mean dB", np.round(np.sort(cluster_db)[::-1][:5], 0))
print("most common clusters:", np.argsort(-counts)[:5], "with shares", np.round(np.sort(counts)[::-1][:5] / n_frames, 2))
k-means: 32 clusters fitted on 27270 frames in 2.1 s quiet (silence/noise) clusters: [1, 4, 8, 9, 18, 22, 30, 31] mean dB [-62. -63. -70. -78. -74. -64. -64. -69.] -> 29 % of all frames loudest clusters: [ 6 13 19 2 21] mean dB [-32. -38. -38. -42. -43.] most common clusters: [30 10 27 31 22] with shares [0.05 0.05 0.05 0.05 0.05]
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short plot the log-mel spectrogram of clip 0 with its cluster-id sequence underneath, and print the first 40 ids.
- Top: the spectrogram. Bottom: one colour per cluster id, one column per 10 ms. Quiet clusters are drawn white.
- The text of the clip (Khmer) is printed with the clip id.
What the output means¶
In short clip iany_046e9e11_cc152fc8 (speaker iany-s-046e9e11) and the first 40 ids, e.g. [31 22 31 30 22 22 30 30 30 30 4 15 15 15 22 ...].
- Ids 31, 22, 30 and 4 are quiet clusters. The first frames of this clip are silence or noise.
# plot: spectrogram of one probe clip with its cluster-id sequence underneath
i = 0
x, ids = probe_wavs[i], labels[i]
text = probe_meta.text[i]; print(probe_meta.utt_id[i], probe_meta.speaker[i], "|", text)
fig, ax = plt.subplots(2, 1, figsize=(12, 5), sharex=True, gridspec_kw={"height_ratios": [3, 1]})
show_spec(x, ax=ax[0], mel=True, title=f"log-mel of {probe_meta.utt_id[i]} ({probe_meta.sentence_id[i]})")
t = np.arange(len(ids)) * HOP / SR
ax[1].imshow(ids[None, :], aspect="auto", cmap="tab20", extent=[0, t[-1] + HOP / SR, 0, 1], interpolation="nearest")
ax[1].set(yticks=[], xlabel="time (s)", title=f"k-means cluster id per 10 ms frame (k={K}; white = quiet clusters {sorted(SIL)})")
# mark the quiet clusters in white so you can line them up with the dark gaps above
ax[1].imshow(np.where(is_sil(ids), 1.0, np.nan)[None, :], aspect="auto", cmap="gray", vmin=0, vmax=1,
extent=[0, t[-1] + HOP / SR, 0, 1], interpolation="nearest")
plt.tight_layout(); plt.savefig(OUT / "kmeans_clusters.png", dpi=110); plt.show()
print("cluster ids, first 40 frames:", ids[:40])
iany_046e9e11_cc152fc8 iany-s-046e9e11 | ពួកយើងលេងបាល់ទាត់នៅទីលានក្រោយម៉ោងរៀន
cluster ids, first 40 frames: [31 22 31 30 22 22 30 30 30 30 4 15 15 15 22 4 22 22 22 22 30 22 22 22 22 22 22 22 30 22 30 22 15 22 22 22 22 22 22 22]
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short play clip 0.
play(x)shows an audio player for the clip plotted above.
What the output means¶
In short an audio player (press play).
- Listen and compare with the white (quiet) and coloured parts of the plot.
play(x)
Additional Reading¶
How to read this plot¶
In short white = quiet clusters (the first 0.7 s and two short pauses). A run of one colour = a sound held for a while. The ids are arbitrary: only same vs different counts.
Top: the log-mel spectrogram (bright = energy). Bottom: one colour per cluster id. Read it as a crude transcript made without text.
- White columns = quiet clusters. They line up with the dark parts of the spectrogram: the first 0.7 s before speech starts, and short pauses near 1.55 s and 2.05 to 2.1 s (computed separately, not printed by the cells). The clip ends in speech, so there is no white tail.
- A run of one colour = the same kind of frame held for a while. The clip has 317 frames and 89 runs (mean 3.6 frames, longest 15; 26 runs are 5 frames or longer) (computed separately, not printed by the cells). The course reads the long runs as held vowels and the short runs at their edges as consonants. We did not check this against a transcript (course explanation, not tested here).
- The same colour again later = the same cluster again. In the picture the purple cluster (id 15) appears at about 1.2 to 1.35 s, at 1.8 to 1.9 s, and again at 2.4, 2.8 and 3.1 s (computed separately, not printed by the cells). It also appears twice in the first 0.35 s (0.11 and 0.32 s), where there is no speech. So a cluster is not a clean vowel; it is a group of similar-looking frames.
- Cluster boundaries are noisy at syllable edges: k-means does not know about time, so it switches colour whenever a frame drifts across a centroid border. The course says HuBERT reduces this by re-clustering its own Transformer features in the second round (background fact from the course text, not checked here).
Additional reading: you can read all of it or just read the "In short"¶
Step (c): masked prediction¶
In short chance is 3.47 nats; the 'most common cluster' baseline is 2.95 nats. Training must get below 2.95.
3c. Mask spans, predict the cluster id¶
The model sees the standardised log-mel sequence with about 30 % of the frames replaced by a learned [MASK] vector. The masks come in spans of 8 frames (80 ms), so the model cannot just copy the neighbour. A 2-layer Transformer encoder (d = 128, 4 heads) predicts the k-means id at every masked frame:
$$\mathcal{L} = -\frac{1}{|M|}\sum_{t \in M} \log p_\theta(z_t \mid \tilde{m}_{1..T})$$
$M$ is the set of masked frames, $z_t$ the pseudo-label, $\tilde m$ the masked input. Chance level is $\ln 32 = 3.47$ nats.
Predict first. Will the loss on masked frames go below the loss you get by always predicting the most common cluster? That baseline is $-\log(\text{share of the most common cluster})$. The cell prints it; compare after training.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short define the mini model, the span-mask function and the 2 s windows, and print the training set and the two baselines.
MiniHuBERT: a linear layer (80 → 128, "layer 0"), a learned [MASK] vector, learned positions, 2 Transformer layers, a linear head with 32 outputs. It also returns the hidden states of every layer for probing.span_mask: picks span starts so that about 30 % of the frames are hidden.make_chunks: cuts every clip into non-overlapping windows of 200 frames (2 s). Shorter tails are dropped.
What the output means¶
In short 100 windows of 200 frames × 80 mels; chance 3.47 nats; baseline 2.95 nats; 0.94 M parameters.
- 100 windows × 2 s = 200 s of the 272 s. The rest is lost in the tails.
- The cell prints "the clusters are nearly equally common". The shares range from 1.3 % to 5.2 % (computed separately, not printed by the cells), so the biggest cluster is about 4 times the smallest. The baseline (2.95) is 0.52 nats below chance (3.47).
- The 0.94 M parameters are about 1 % of a real HuBERT-base (background fact from the course text, not checked here).
class MiniHuBERT(nn.Module):
'''Masked prediction over log-mel frames. Returns per-layer hidden states for probing.'''
def __init__(self, n_in=80, d=128, n_layers=2, n_heads=4, n_classes=K, max_len=4096):
super().__init__()
self.proj = nn.Linear(n_in, d) # "layer 0" = projected input frames
self.mask_emb = nn.Parameter(torch.randn(d) * 0.1) # the [MASK] vector that replaces hidden frames
self.pos = nn.Embedding(max_len, d) # learned absolute positions (real models use a conv pos-encoder)
layer = nn.TransformerEncoderLayer(d, n_heads, dim_feedforward=4 * d, dropout=0.1, batch_first=True, norm_first=True)
self.layers = nn.ModuleList([nn.TransformerEncoderLayer(d, n_heads, 4 * d, 0.1, batch_first=True, norm_first=True)
for _ in range(n_layers)])
self.norm = nn.LayerNorm(d)
self.head = nn.Linear(d, n_classes) # predicts the cluster id
def forward(self, x, mask=None): # x: (B, T, 80); mask: (B, T) bool, True = hidden
h = self.proj(x)
if mask is not None:
h = torch.where(mask[..., None], self.mask_emb.expand_as(h), h)
h = h + self.pos(torch.arange(x.shape[1], device=x.device))[None]
hidden = [h]
for layer in self.layers:
h = layer(h); hidden.append(h)
return self.head(self.norm(h)), hidden # logits (B, T, K), list of (B, T, d) per layer
def span_mask(B, T, p=0.30, span=8, rng=np.random):
'''Boolean (B, T): choose span starts so that about p of all frames end up masked.'''
m = np.zeros((B, T), bool)
n_starts = int(p * T / span * 1.15) # 15 % extra because spans overlap
for b in range(B):
for s in rng.choice(T - span, n_starts, replace=False):
m[b, s:s + span] = True
return torch.from_numpy(m)
CHUNK = 200 # 2 s windows
def make_chunks(feats, labs, chunk=CHUNK):
X, Y = [], []
for f, l in zip(feats, labs):
for s in range(0, len(f) - chunk + 1, chunk): # non-overlapping 2 s windows
X.append(f[s:s + chunk]); Y.append(l[s:s + chunk])
return torch.from_numpy(np.stack(X)), torch.from_numpy(np.stack(Y))
X_train, Y_train = make_chunks(mels_z, labels)
print(f"training windows: {X_train.shape} (windows x frames x mels)")
share_top = counts.max() / n_frames
print(f"chance loss ln({K}) = {np.log(K):.2f} nats; 'always the most common cluster' baseline = {-np.log(share_top):.2f} nats")
print("(the clusters are nearly equally common, so that baseline is only a little below chance)")
model = MiniHuBERT()
print(f"mini model parameters: {sum(p.numel() for p in model.parameters())/1e6:.2f} M")
training windows: torch.Size([100, 200, 80]) (windows x frames x mels) chance loss ln(32) = 3.47 nats; 'always the most common cluster' baseline = 2.95 nats (the clusters are nearly equally common, so that baseline is only a little below chance) mini model parameters: 0.94 M
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short train the mini model for 16 epochs with the loss only at masked frames, measure the accuracy at masked frames, and plot the loss.
- AdamW with a one-cycle learning rate (max 2e-3), batch 16 windows.
F.cross_entropy(logits[mask], yb[mask])is the HuBERT rule: loss only where frames were hidden. - After training: one new random mask (seed 1) over all 100 windows, and the share of masked frames whose id is predicted correctly.
- The plot shows the loss per epoch with the chance line and the baseline line.
What the output means¶
In short loss 3.621 (epoch 0) → 2.678 (epoch 14), 2.679 (epoch 15). Masked fraction in the last batch 0.29. Accuracy at masked frames 22.7 % (most-common-cluster baseline 5.2 %, chance 3.1 %).
- The loss starts above chance (3.621 vs 3.47), then falls. It goes below the baseline at epoch 7 (2.930 < 2.954; this epoch is in the saved history, not printed by the cell) (computed separately, not printed by the cells).
- The loss is not perfectly smooth: it rises a little at epoch 11 (2.780 after 2.752) and is flat at the end (computed separately, not printed by the cells).
- The 22.7 % is measured on the training windows (with a new random mask). It shows that the model uses context. It does not show that it works on new audio.
- The time per epoch is printed in the cell and changes from machine to machine.
EPOCHS, BATCH = 16, 16
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=2e-3, total_steps=EPOCHS * ((len(X_train) + BATCH - 1) // BATCH))
rng = np.random.RandomState(0)
hist = []
t0 = time.time()
for ep in range(EPOCHS):
model.train(); tot, n = 0.0, 0
for idx in np.array_split(rng.permutation(len(X_train)), (len(X_train) + BATCH - 1) // BATCH):
xb, yb = X_train[idx], Y_train[idx]
mask = span_mask(len(idx), CHUNK, rng=rng)
logits, _ = model(xb, mask)
loss = F.cross_entropy(logits[mask], yb[mask]) # loss ONLY at masked frames (the HuBERT rule)
opt.zero_grad(); loss.backward(); opt.step(); sched.step()
tot += loss.item() * mask.sum().item(); n += mask.sum().item()
hist.append(tot / n)
if ep % 2 == 0 or ep == EPOCHS - 1:
print(f"epoch {ep:2d} masked-frame loss {hist[-1]:.3f} nats ({time.time()-t0:.0f} s)")
print(f"masked fraction in the last batch: {mask.float().mean():.2f}")
torch.save(model.state_dict(), OUT / "mini_hubert.pt")
# accuracy at masked frames vs the 'copy the most common id' baseline
model.eval()
with torch.no_grad():
mask = span_mask(len(X_train), CHUNK, rng=np.random.RandomState(1))
logits, _ = model(X_train, mask)
acc_masked = (logits.argmax(-1)[mask] == Y_train[mask]).float().mean().item()
print(f"accuracy at masked frames: {100*acc_masked:.1f} % (most-common-cluster baseline {100*share_top:.1f} %, chance {100/K:.1f} %)")
plt.figure(figsize=(12, 3)); plt.plot(hist, marker="o")
plt.axhline(np.log(K), ls=":", color="gray"); plt.text(0, np.log(K) + 0.05, "chance ln 32", color="gray")
plt.axhline(-np.log(share_top), ls="--", color="gray"); plt.text(0, -np.log(share_top) + 0.05, "most-common-cluster baseline", color="gray")
plt.title("mini-HuBERT: cross-entropy at masked frames"); plt.xlabel("epoch"); plt.ylabel("loss (nats)"); plt.grid(alpha=0.3)
plt.savefig(OUT / "mini_hubert_loss.png", dpi=110); plt.show()
epoch 0 masked-frame loss 3.621 nats (2 s)
epoch 2 masked-frame loss 3.389 nats (5 s)
epoch 4 masked-frame loss 3.133 nats (8 s)
epoch 6 masked-frame loss 2.996 nats (12 s)
epoch 8 masked-frame loss 2.887 nats (16 s)
epoch 10 masked-frame loss 2.752 nats (20 s)
epoch 12 masked-frame loss 2.734 nats (26 s)
epoch 14 masked-frame loss 2.678 nats (29 s)
epoch 15 masked-frame loss 2.679 nats (31 s) masked fraction in the last batch: 0.29
accuracy at masked frames: 22.7 % (most-common-cluster baseline 5.2 %, chance 3.1 %)
Additional Reading¶
How to read this plot¶
In short the curve starts above chance (3.47), falls below the dashed baseline (2.95) at epoch 7, and ends at 2.68. Below the baseline = real prediction from neighbouring frames.
Chance (dotted, 3.47) is a model that guesses all 32 ids with equal probability. The dashed line (2.95) is a model that always says "the most common cluster". The gap between the two lines is "free": a model only has to learn which clusters are common.
Everything below the dashed line needs the neighbouring frames. Our curve ends at 2.68, which is 0.27 below the baseline. That is a small gain for 100 windows and 16 epochs (we did not test longer training).
The course notes that the loss on masked frames stays well above zero even for the real HuBERT, because a hidden 80 ms span has several plausible sounds (background fact from the course text, not checked here).
In the course plot the "chance ln 32" label is crossed by the curve at epoch 1. This is how the course cell draws it.
Additional reading: you can read all of it or just read the "In short"¶
What the model must learn¶
In short the masked-prediction task forces the model to use context. Whether upper layers become 'phonetic' needs a real model.
3d. The trick¶
The model never sees a single character of Khmer. To guess the cluster at a masked position it must learn patterns like "after this cluster and before that cluster, the hidden frames are usually cluster 17". That is phonotactics, learned from sound alone (course explanation, not tested here).
The course says that at 12 layers and 100 000 hours the upper layers of such an encoder correlate with phonemes, and that the speaker's identity (pitch range, formant positions) is also useful context for the guess, so it is kept in the early layers (course explanation, not tested here). Our toy model cannot show this (section 4): the speaker is readable at every layer, and the content is at most slightly above chance at every layer (0.19 to 0.27 vs 0.167, within the fold std).
4. Probing "who vs what" on the mini model¶
A linear probe is a logistic regression trained on frozen features. If a linear classifier can read speaker identity from a layer, that layer linearly encodes speaker identity.
We mean-pool the frames of each clip (one vector per clip) and run two probes on the 48 probe clips (8 speakers × 6 sentences):
- speaker probe (8 classes, chance 12.5 %): train on 5 sentences, test on the 6th. The probe cannot cheat by recognising the sentence. (It is run 6 times, once per held-out sentence.)
- sentence / content probe (6 classes, chance 16.7 %): train on 7 speakers, test on the 8th. It cannot cheat by recognising the voice. (It is run 8 times.)
Layer 0 is the projected input (log-mel through one linear layer, so it is still "the spectrogram"). Layers 1 and 2 are the Transformer outputs. For reference we also probe the raw 80-d log-mel.
What our run shows: the speaker is readable at every layer (0.979 = 47 of 48 clips). The sentence is only slightly above chance and does not rise with depth. The mini model does not show the "content rises" pattern of real models.
Why it matters: this is the measurement kNN-VC relies on. We check the method at toy scale before trusting it at full scale.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short take the hidden states of the mini model for the 48 probe clips, and run the speaker probe and the content probe on four feature sets: log-mel, L0, L1, L2.
mini_layer_features: runs the model without masking and keeps the hidden states of every layer.probe_layers(..., labels=spk, groups=sent): speaker probe with sentences held out.probe_layers(..., labels=sent, groups=spk): content probe with speakers held out.- The table shows the mean accuracy over the folds and its std over the folds. It is saved to
outputs/day5/mini_probe_results.csv.
What the output means¶
In short speaker accuracy 0.979 on all four (std 0.047). Sentence accuracy: log-mel 0.271, L0 0.208, L1 0.188, L2 0.229 (std 0.116 / 0.072 / 0.055 / 0.116). Chance: speaker 0.125, sentence 0.167.
- 0.979 is 47 of 48 clips. It is the same at every layer, even at raw log-mel.
- Sentence accuracy is 0.19 to 0.27. The differences between layers (0.02 to 0.08) are about as large as the fold std (0.06 to 0.12), so we cannot rank the layers.
- No layer is clearly above chance beyond its fold std: log-mel is 0.10 above 0.167 (std 0.116), and L1 (0.188) is only 0.02 above.
@torch.no_grad()
def mini_layer_features(feats):
'''Per-layer frame features (no masking) for a list of (T, 80) standardised log-mel clips.'''
model.eval()
layers = [[] for _ in range(len(model.layers) + 1)]
for f in feats:
_, hidden = model(torch.from_numpy(f)[None])
for L, h in enumerate(hidden):
layers[L].append(h[0].numpy())
return layers
mini_layers = mini_layer_features(mels_z[:48]) # probe clips only
layer_frames = [mels_z[:48]] + mini_layers # [log-mel, layer0, layer1, layer2]
layer_names = ["log-mel", "L0 (proj. input)", "L1", "L2"]
spk, sent = probe_meta.speaker.values, probe_meta.sentence_id.values
spk_m, spk_s = probe_layers(layer_frames, labels=spk, groups=sent) # speaker probe, sentences held out
con_m, con_s = probe_layers(layer_frames, labels=sent, groups=spk) # content probe, speakers held out
mini_results = pd.DataFrame({"layer": layer_names, "speaker_acc": spk_m.round(3), "speaker_std": spk_s.round(3),
"sentence_acc": con_m.round(3), "sentence_std": con_s.round(3)})
mini_results.to_csv(OUT / "mini_probe_results.csv", index=False)
print(mini_results.to_string(index=False))
print(f"chance: speaker {1/8:.3f}, sentence {1/6:.3f}")
layer speaker_acc speaker_std sentence_acc sentence_std
log-mel 0.979 0.047 0.271 0.116
L0 (proj. input) 0.979 0.047 0.208 0.072
L1 0.979 0.047 0.188 0.055
L2 0.979 0.047 0.229 0.116
chance: speaker 0.125, sentence 0.167
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short plot the probe accuracies as bars (left) and the linear CKA similarity between the four layers (right).
- Left: speaker (blue) and sentence (orange) bars with fold std; dotted lines = chance.
- Right:
layer_similarity= linear CKA between the pooled features of each pair of layers (1 = same geometry). The numbers are printed in the cells.
What the output means¶
In short bars as in the table; CKA: log-mel–L0 0.96, L0–L1 0.92, L1–L2 0.95, log-mel–L1 0.80, L0–L2 0.81, log-mel–L2 0.67.
- L2 is the least similar to the input (0.67). L1 and L2 are more similar to each other (0.95) than to log-mel (0.80 and 0.67).
fig, ax = plt.subplots(1, 2, figsize=(12, 3.5))
w = 0.38; x = np.arange(len(layer_names))
ax[0].bar(x - w/2, spk_m, w, yerr=spk_s, capsize=3, label="speaker (8 classes)")
ax[0].bar(x + w/2, con_m, w, yerr=con_s, capsize=3, label="sentence (6 classes)")
ax[0].axhline(1/8, ls=":", color="C0"); ax[0].axhline(1/6, ls=":", color="C1")
ax[0].set(xticks=x, xticklabels=layer_names, ylim=(0, 1.05), ylabel="held-out accuracy", title="mini model: linear-probe accuracy per layer")
ax[0].legend(loc="upper right"); ax[0].grid(alpha=0.3, axis="y")
S = layer_similarity([pool_features(f) for f in layer_frames])
im = ax[1].imshow(S, vmin=0, vmax=1, cmap="magma")
ax[1].set(xticks=x, yticks=x, xticklabels=layer_names, yticklabels=layer_names, title="linear CKA between layers (pooled)")
for i in range(len(x)):
for j in range(len(x)):
ax[1].text(j, i, f"{S[i,j]:.2f}", ha="center", va="center", color="w" if S[i,j] < 0.6 else "k", fontsize=8)
plt.colorbar(im, ax=ax[1]); plt.tight_layout(); plt.savefig(OUT / "mini_probe_layers.png", dpi=110); plt.show()
Additional Reading¶
How to read this plot¶
In short left: speaker bars are all near 1.0; sentence bars are low, near chance, with big error bars. Right: the further a layer is from the input, the less it looks like log-mel (CKA down to 0.67).
Left. Two bars per layer. The dotted lines are chance (blue 0.125 for the speaker, orange 0.167 for the sentence).
The course says that in real 12-layer models the speaker curve is high early and drops toward the top, and the content curve rises toward the upper-middle layers (background fact from the course text, not checked here). With 2 layers, 200 s of training audio, 48 clips for the probes and mean-pooled clips, a toy can only show the direction. Read it honestly:
- Speaker: 0.979 everywhere, no drop with depth. Mean-pooled log-mel already holds pitch range and timbre, and a short training has no reason to remove them (our reading, not tested here). The course says nothing short of the full model would remove this signal (course explanation, not tested here).
- Sentence: 0.27 on log-mel and 0.19 to 0.23 on L0 to L2. It is close to chance (0.167) and does not rise through the layers. L2 (0.229) is lower than log-mel (0.271). So masked prediction has not added content information that survives mean pooling. The small signal in log-mel probably comes from gross shape such as clip length and loudness profile. (course explanation, not tested here)
- All four sentence bars overlap with chance within their error bars. In the course plot the legend covers the top of the L2 speaker bar. Read the table instead.
Right. Each cell is the CKA of two layers. The Transformer rewrites the features step by step: log-mel to L0 is almost unchanged (0.96, one linear layer), and L2 is the farthest from the input (0.67).
The course says exact numbers move by a point or two between runs (k-means and the random start). Read trends, not decimals. The real-model curves of section 5 would be the real evidence, but they did not run here (downloads blocked), so this section is only the method, tried on a toy. Run section 5 on a machine with internet. The x tick labels "log-mel" and "L0 (proj. input)" touch each other in the right panel: this is how the course cell draws it.
5. The real encoders (needs internet the first time)¶
FALLBACK Not run here. The download of WavLM, HuBERT and mHuBERT-147 fails on the machine where this file was built. The three cells below ran, printed the failure, and used the course's guard. No real-encoder number exists in this notebook. On a machine with internet the same cells give the real curves. The only table printed here is
data/meta/ssl_probe_reference.csv: it has words (rising / high / falling), not numbers.
The plan: run the same two probes on every layer of WavLM-base-plus (12 Transformer layers + the input = 13 hidden states, 768-d, 50 frames/s). output_hidden_states=True returns all of them in one forward pass.
| model | HF name | download |
|---|---|---|
| WavLM-base-plus | microsoft/wavlm-base-plus |
~380 MB |
| HuBERT-base | facebook/hubert-base-ls960 |
~380 MB |
| mHuBERT-147 | utter-project/mHuBERT-147 |
~380 MB |
| wav2vec 2.0 base | facebook/wav2vec2-base |
~380 MB |
The sizes are from the course (background fact, not checked here). The cells load only the first three; wav2vec 2.0 is in the table and in the shipped reference file, but no cell loads it.
Why it matters: this is the step where the project question "who vs what, per layer" gets a real answer. Everything before it is method.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short define try_load_ssl and try to load WavLM-base-plus from Hugging Face.
try_load_ssl(hf_name, short)callsAutoFeatureExtractorandAutoModelinsidetry/except. On success the model goes intoSSL_MODELS. On failure it prints a message and the notebook continues.- Needs internet the first time; the cache is
~/.cache/huggingface.
What the output means¶
In short FALLBACK: [offline] could not load microsoft/wavlm-base-plus: OSError: Can't load feature extractor ... and the line skipping WavLM-base+.
- Nothing was loaded:
SSL_MODELSis empty. The next cells take theirelsebranch. - This is the blocked-download case, not a bug in the code.
# needs internet (first run only) -- microsoft/wavlm-base-plus, ~380 MB, cached under ~/.cache/huggingface
SSL_MODELS = {} # name -> loaded model (only the ones that could be downloaded)
def try_load_ssl(hf_name, short):
try:
from transformers import AutoModel, AutoFeatureExtractor
fx = AutoFeatureExtractor.from_pretrained(hf_name, cache_dir=CACHE / "huggingface")
m = AutoModel.from_pretrained(hf_name, cache_dir=CACHE / "huggingface").eval()
SSL_MODELS[short] = (fx, m)
print(f"loaded {short} ({hf_name}): {sum(p.numel() for p in m.parameters())/1e6:.0f} M params")
except Exception as e:
print(f"[offline] could not load {hf_name}: {type(e).__name__}: {str(e)[:120]}")
print(f" -> skipping {short}; run this cell on a machine with internet to fill in the real curves.")
try_load_ssl("microsoft/wavlm-base-plus", "WavLM-base+")
[offline] could not load microsoft/wavlm-base-plus: OSError: Can't load feature extractor for 'microsoft/wavlm-base-plus'. If you were trying to load it from 'https://huggingface.co
-> skipping WavLM-base+; run this cell on a machine with internet to fill in the real curves.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short extract the 13 layers of WavLM for the 48 probe clips and probe them. If WavLM is missing, print the shipped reference table.
- With WavLM: features for 48 clips, the two probes per layer, a table, a curve plot and a CKA map (all saved to
outputs/day5/). - Without WavLM (our case): read
data/meta/ssl_probe_reference.csvand print the WavLM rows (layer, expected speaker trend, expected content trend) and the file's note.
What the output means¶
In short FALLBACK: the cell prints "WavLM not available -> showing the shipped qualitative reference (no numbers; re-run on your laptop)" and a 13-row table of words.
- The table is not a measurement. Speaker trend: rising at layers 0-1, high (near peak) at 2-6, falling at 7-12. Content trend: rising at 0-7, high (near peak) at 8-11, falling at 12.
- The note in the file says: qualitative only; the SUPERB layer-weight analysis (Chen et al. 2022) puts speaker tasks early and phonetic tasks late; re-run to fill the numbers (background fact from the course text, not checked here).
@torch.no_grad()
def ssl_layer_features(short, wavs, max_clips=None):
'''Run one SSL model on a list of waveforms. Returns list over layers (13) of lists over clips of (T, 768).'''
fx, m = SSL_MODELS[short]
layers = None
for w in (wavs if max_clips is None else wavs[:max_clips]):
inp = fx(w, sampling_rate=SR, return_tensors="pt")
hs = m(**inp, output_hidden_states=True).hidden_states # tuple of 13 x (1, T, 768)
if layers is None:
layers = [[] for _ in hs]
for L, h in enumerate(hs):
layers[L].append(h[0].numpy())
return layers
ssl_probe = {} # short -> DataFrame of per-layer accuracies
if "WavLM-base+" in SSL_MODELS:
t0 = time.time()
wavlm_layers = ssl_layer_features("WavLM-base+", probe_wavs)
print(f"WavLM features for 48 clips in {time.time()-t0:.0f} s; layer 6 of clip 0: {wavlm_layers[6][0].shape}")
m_spk, s_spk = probe_layers(wavlm_layers, labels=spk, groups=sent)
m_con, s_con = probe_layers(wavlm_layers, labels=sent, groups=spk)
ssl_probe["WavLM-base+"] = pd.DataFrame({"layer": range(len(wavlm_layers)), "speaker_acc": m_spk, "speaker_std": s_spk,
"sentence_acc": m_con, "sentence_std": s_con})
ssl_probe["WavLM-base+"].to_csv(OUT / "wavlm_probe_results.csv", index=False)
print(ssl_probe["WavLM-base+"].round(3).to_string(index=False))
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8), gridspec_kw={"width_ratios": [2, 1]})
plot_layer_curves({"speaker (8)": (m_spk, s_spk), "sentence (6)": (m_con, s_con)},
chance={"spk": 1/8, "sent": 1/6}, title="WavLM-base+: linear-probe accuracy per layer", ax=ax[0])
S = layer_similarity([pool_features(f) for f in wavlm_layers])
im = ax[1].imshow(S, vmin=0, vmax=1, cmap="magma"); ax[1].set(title="linear CKA between layers", xlabel="layer", ylabel="layer")
plt.colorbar(im, ax=ax[1]); plt.tight_layout(); plt.savefig(OUT / "wavlm_probe_layers.png", dpi=110); plt.show()
else:
ref = pd.read_csv(DATA / "meta/ssl_probe_reference.csv")
print("WavLM not available -> showing the shipped *qualitative* reference (no numbers; re-run on your laptop):")
print(ref[ref.model == "microsoft/wavlm-base-plus"][["layer", "speaker_trend_expected", "content_trend_expected"]].to_string(index=False))
print("\nnote:", ref.note.iloc[0])
WavLM not available -> showing the shipped *qualitative* reference (no numbers; re-run on your laptop):
layer speaker_trend_expected content_trend_expected
0 rising rising
1 rising rising
2 high (near peak) rising
3 high (near peak) rising
4 high (near peak) rising
5 high (near peak) rising
6 high (near peak) rising
7 falling rising
8 falling high (near peak)
9 falling high (near peak)
10 falling high (near peak)
11 falling high (near peak)
12 falling falling
note: qualitative only; SUPERB layer-weight analysis (Chen et al. 2022) puts speaker tasks early, phonetic/ASR tasks late. Re-run Section 5 on your laptop to fill the numbers.
Additional reading: you can read all of it or just read the "In short"¶
What the real curves should look like (not shown here)¶
In short the course expects speaker info early and content later. We could not test this: no real encoder ran.
What to expect when the cells run on a machine with internet (the course text; none of it is shown here):
- Speaker curve: high from layer 1-2 on, staying high through the middle, then flattening or dipping in the top 2-3 layers. With only 8 speakers even the top layers will be far above 12.5 %, so look at the shape, not the absolute value.
- Sentence curve: lowest at layer 0, rising through the middle, highest somewhere in layers 7-11, where SUPERB's phoneme-recognition weights also peak.
- CKA map: a bright block along the diagonal is the normal "neighbouring layers are similar" picture. The last 1-2 layers often form their own block because they specialise for the pretext loss.
The shipped table is a little different from this text. It says the speaker trend is falling from layer 7 (six layers), while the text says "the top 2-3 layers". It says the content trend peaks at layers 8-11 and falls at layer 12, while the text says 7-11. Both are expectations. A real run will settle it.
If your curves do not look like this, the first suspects are the pooling (try stats=True in pool_features) and the tiny probe set. The literature uses hundreds of speakers (background fact from the course text, not checked here).
Next: the same for HuBERT-base and mHuBERT-147, overlaid on the WavLM curves. mHuBERT-147 is the one to watch for Khmer: if it has Khmer in pre-training, its content curve should be higher than the English-only models at the same layer (course explanation, not tested here). Whether Khmer is in its 147 languages is not checked here (see the table in section 1).
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short try to load HuBERT-base and mHuBERT-147, probe each if available, and plot the overlay. If none is available, print what to do.
- Two more
try_load_sslcalls. For every loaded model: all layers, the two probes, a CSV inoutputs/day5/, and then one overlay plot (speaker left, sentence right). - If
ssl_probeis empty, theelsebranch prints two lines and draws nothing.
What the output means¶
In short FALLBACK: two [offline] could not load ... messages (HuBERT-base, mHuBERT-147), then "No pretrained encoder available in this session."
- No CSV and no plot is made for the real encoders here. The files
wavlm_probe_results.csv,hubert_base_probe_results.csvand so on are not written. - On a machine with internet this cell draws the overlay.
# needs internet (first run only) -- facebook/hubert-base-ls960 (~380 MB) and utter-project/mHuBERT-147 (~380 MB)
try_load_ssl("facebook/hubert-base-ls960", "HuBERT-base")
try_load_ssl("utter-project/mHuBERT-147", "mHuBERT-147")
for short in ["HuBERT-base", "mHuBERT-147"]:
if short in SSL_MODELS:
lay = ssl_layer_features(short, probe_wavs)
m_spk, s_spk = probe_layers(lay, labels=spk, groups=sent)
m_con, s_con = probe_layers(lay, labels=sent, groups=spk)
ssl_probe[short] = pd.DataFrame({"layer": range(len(lay)), "speaker_acc": m_spk, "speaker_std": s_spk,
"sentence_acc": m_con, "sentence_std": s_con})
ssl_probe[short].to_csv(OUT / f"{short.lower().replace('-', '_')}_probe_results.csv", index=False)
if ssl_probe:
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8), sharey=True)
for short, df in ssl_probe.items():
ax[0].errorbar(df.layer, df.speaker_acc, yerr=df.speaker_std, marker="o", capsize=2, label=short)
ax[1].errorbar(df.layer, df.sentence_acc, yerr=df.sentence_std, marker="o", capsize=2, label=short)
ax[0].axhline(1/8, ls=":", color="gray"); ax[1].axhline(1/6, ls=":", color="gray")
ax[0].set(title="speaker probe (8 classes) per layer", xlabel="layer", ylabel="held-out accuracy", ylim=(0, 1.05))
ax[1].set(title="sentence probe (6 classes) per layer", xlabel="layer")
for a in ax: a.grid(alpha=0.3); a.legend()
plt.tight_layout(); plt.savefig(OUT / "ssl_probe_overlay.png", dpi=110); plt.show()
else:
print("No pretrained encoder available in this session. The plotting code above is what to run on your laptop;")
print("expected shapes are described in the reading guide, and data/meta/ssl_probe_reference.csv lists the qualitative trends.")
[offline] could not load facebook/hubert-base-ls960: OSError: Can't load feature extractor for 'facebook/hubert-base-ls960'. If you were trying to load it from 'https://huggingface.c
-> skipping HuBERT-base; run this cell on a machine with internet to fill in the real curves.
[offline] could not load utter-project/mHuBERT-147: OSError: Can't load feature extractor for 'utter-project/mHuBERT-147'. If you were trying to load it from 'https://huggingface.co
-> skipping mHuBERT-147; run this cell on a machine with internet to fill in the real curves.
No pretrained encoder available in this session. The plotting code above is what to run on your laptop;
expected shapes are described in the reading guide, and data/meta/ssl_probe_reference.csv lists the qualitative trends.
6. Which layer for voice conversion?¶
FALLBACK in the code of this section. WavLM is not available here, so the cell below uses the mini model's layer 2 as a stand-in. Every number and picture of this section is about the mini model, not about WavLM layer 6.
kNN-VC (Baas et al., 2023) picks layer 6 of WavLM-large and never trains a content encoder (background fact from the course text, not checked here). The choice is a compromise between two needs:
- Phonetic enough. Frames of the same sound from different speakers must land close together. Then replacing a source frame by its nearest target-pool frame keeps the sound. (The content curve must already be rising at this layer.)
- Acoustic enough. The vocoder (HiFi-GAN trained on layer-6 features (background fact from the course text, not checked here)) must still be able to make a waveform. So the layer must keep fine detail like pitch and energy. (The speaker curve must not have collapsed.)
Layer 6 of the 24-layer WavLM-large is one quarter of the way up (6 of 24). The course says the authors chose it from a small sweep and that it is not a law (background fact from the course text, not checked here). For a 12-layer base model the course suggests layers 3 to 6 (background fact from the course text, not checked here).
The picture to check: take frames from several clips and project them to 2-D. Colour the same points once by speaker and once by k-means sound cluster (from section 3). If the colours group by sound and not by speaker, the layer carries content and the nearest-neighbour swap keeps the words. If they group by speaker, kNN-VC would find neighbours of the same voice and the anonymization would leak.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short pick 4 speakers × 2 sentences, take their layer-2 frames (the stand-in), project to 2-D with PCA, colour by speaker and by sound cluster, and compute two 5-NN accuracies.
sel: the probe clips of the first 4 speakers (sorted by id) for sentences daily-04 and news-03: 8 clips. Without WavLM,frames= the mini model's layer 2 andsound_ids= the k-means ids of the same frames.- Silent frames are dropped (they would form one big blob). The features are standardised, then PCA gives the 2-D map.
- 5-NN accuracy is computed in the full feature space (not on the 2-D map): can a nearest-neighbour vote predict the speaker, or the sound cluster?
What the output means¶
In short FALLBACK (mini model layer 2): 5-NN accuracy predicting the speaker 100 % (chance 25 %); predicting the sound cluster 44 % (chance 4 %). 2631 non-silent frames from 8 clips.
- The cell's code comment says "2 f, 2 m". The 4 speakers are all female (checked in
probe_clips.csv) (computed separately, not printed by the cells). So the speaker task is 4 similar voices, not 2 + 2. - The sound task has 24 classes (the non-quiet clusters present in these frames) (computed separately, not printed by the cells), so chance is 100/24 ≈ 4 %. The course text says "3 %"; the cell prints 4 %.
- Caution (our reading, not tested): the 5-fold cross-validation is not shuffled and the frames are neighbours in time. Frames of the same clip can sit in both the training and the test folds. This can make both numbers too high.
from sklearn.decomposition import PCA
# pick 4 speakers (2 f, 2 m) x 2 sentences so both colourings have something to show
sel = probe_meta[probe_meta.sentence_id.isin(["daily-04", "news-03"])]
sel = sel[sel.speaker.isin(sorted(sel.speaker.unique())[:4])]
idx = sel.index.values
if "WavLM-base+" in SSL_MODELS:
frames = [wavlm_layers[6][i] for i in idx]; src_name = "WavLM-base+ layer 6"
# WavLM runs at 50 fps, our k-means ids at 100 fps -> take every second id
sound_ids = [labels[i][1::2][: len(fr)] for i, fr in zip(idx, frames)]
else:
frames = [mini_layers[-1][i] for i in idx]; src_name = "mini model layer 2 (stand-in; WavLM not available)"
sound_ids = [labels[i][: len(fr)] for i, fr in zip(idx, frames)]
Xf = np.concatenate(frames); Xf = (Xf - Xf.mean(0)) / (Xf.std(0) + 1e-5)
spk_of_frame = np.concatenate([[s] * len(fr) for s, fr in zip(sel.speaker, frames)])
snd_of_frame = np.concatenate(sound_ids)
keep = ~is_sil(snd_of_frame) # drop silence/noise frames: they would form one big blob
P = PCA(2, random_state=0).fit_transform(Xf[keep])
spk_codes = pd.factorize(spk_of_frame[keep])[0]
fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))
ax[0].scatter(P[:, 0], P[:, 1], c=spk_codes, cmap="tab10", s=4, alpha=0.6)
ax[0].set(title=f"{src_name}: frames coloured by SPEAKER (4 speakers)", xlabel="PC1", ylabel="PC2")
ax[1].scatter(P[:, 0], P[:, 1], c=snd_of_frame[keep], cmap="tab20", s=4, alpha=0.6)
ax[1].set(title="same frames coloured by k-means SOUND cluster", xlabel="PC1")
plt.tight_layout(); plt.savefig(OUT / "layer_pca_speaker_vs_sound.png", dpi=110); plt.show()
# a number instead of eyeballing: how well does a nearest-neighbour vote predict each label from the 2-D map?
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import cross_val_score
for name, y in [("speaker", spk_codes), ("sound cluster", snd_of_frame[keep])]:
acc = cross_val_score(KNeighborsClassifier(5), Xf[keep], y, cv=5).mean()
print(f"5-NN accuracy in the full feature space, predicting {name:13s}: {100*acc:.0f} % (chance {100/len(np.unique(y)):.0f} %)")
print(f"({keep.sum()} non-silent frames from {len(idx)} clips)")
5-NN accuracy in the full feature space, predicting speaker : 100 % (chance 25 %) 5-NN accuracy in the full feature space, predicting sound cluster: 44 % (chance 4 %) (2631 non-silent frames from 8 clips)
Additional Reading¶
How to read this plot¶
In short stand-in result: the mini model's layer 2 is organised by voice first (speaker 100 %), by sound second (44 %). A kNN-VC built on it would, by the course's reading, pick neighbours of the same voice (course explanation, not tested here).
Same points, two colourings. Look for stripes or islands.
- Right panel (sound clusters): an island of one colour is a sound class whose frames sit together. In our picture the colours are mixed in every group, with a few patches (for example one cyan patch in the lower right).
- Left panel (speakers): if an island held all four speaker colours mixed, that sound would be stored the same way for every voice. That is what kNN-VC needs. We see the opposite. Two speakers sit far from the rest, and the other two sit side by side. Each speaker has its own region. The 100 % (5-NN, full space) says that all four can be told apart.
The two 5-NN numbers make it quantitative. For this toy layer: speaker 100 % (chance 25 %) and sound 44 % (chance 4 %). Both are far above chance, and the speaker number is much higher. So this layer is organised by voice first, sound second.
In the real WavLM layer 6 the course expects the speaker number to drop and the sound number to rise. It says both are usually still high, which is why kNN-VC also needs the target pool to contain every sound of the source language (next section) (course explanation, not tested here). We could not test any of this with WavLM.
Also, with clusters made from log-mel the right panel is partly circular: the clusters came from similar features. The WavLM run on a machine with internet is the real test.
The left plot title is cut at the edge (the first letter "m" of "mini model layer 2" is missing); this is how the course cell draws it.
7. Target-pool coverage: Khmer pool vs English pool¶
kNN-VC replaces each source frame by the average of its k nearest frames in the target pool (a few minutes of the target speaker). If the pool has no frame close to a source frame, the nearest neighbour is far away and the output gets the wrong sound.
Our research question: does an English pool (LibriSpeech speaker 121) cover the sounds of Khmer? (The pool descriptions come from the course; the file names do not say how many speakers a pool has (background fact from the course text, not checked here).)
Two measurements, on standardised log-mel (no download needed; the same code works on any layer's features):
- Nearest-neighbour distance for every non-silent frame $f_t$ of a Khmer probe clip: $d_t = \min_{j \in \text{pool}} \| f_t - p_j \|_2$, against the Khmer pool and against the English pool.
- Sound-cluster coverage. Label every pool frame with the k-means sound cluster of section 3. Compare, cluster by cluster, how often the pool has that sound and how often the probe clips need it. A sound the probe uses often and the pool almost never has is a "missing sound".
Silence and noise-floor frames are removed on both sides: silence matches silence in any language and would hide everything.
What our run shows: on log-mel the two pools look similar to each other (same distances, same share of misses), and neither matches the need closely. This is a real result of the cells, but the course says log-mel is the wrong place to look (see the end of this section).
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short load both pools, label their frames with the k-means clusters, drop quiet frames, and find for every non-silent probe frame its 4 nearest pool frames.
pool_framesreads all wave files of a pool folder and standardises with the same mean and std as before.NearestNeighbors(4)is fitted on the non-quiet pool frames. For each probe frame: the distance to the nearest pool frame, and the share of the 4 neighbours that have the same sound cluster as the probe frame.- "Far" = farther than the 95th percentile of the Khmer-pool distances (6.24).
What the output means¶
In short Khmer pool: 22 clips, 18208 frames (182 s), 26 % quiet. English pool: 22 clips, 19869 frames (199 s), 33 % quiet. Median distance 4.28 (Khmer) vs 4.31 (English). 'Far' frames (d > 6.24): 5.0 % vs 4.1 %. 4 neighbours keep the sound: 64.0 % vs 63.1 %.
- The two pools are almost the same on all three numbers. The English pool even has fewer 'far' frames (4.1 % vs 5.0 %).
- By gender (mean over clips): distance Khmer 4.36 / 4.52 and English 4.42 / 4.54 (female / male); same-sound rate Khmer 0.617 / 0.668 and English 0.612 / 0.655.
- The pools hold 18 000 to 20 000 frames, not "20 000" for both (the course text rounds).
from sklearn.neighbors import NearestNeighbors
def pool_frames(folder):
fr = [(logmel(load(p)).T - mel_mean) / mel_std for p in sorted((DATA / f"audio/{folder}").glob("*.wav"))]
return np.concatenate(fr).astype(np.float32), len(fr)
pool_kh, n_kh = pool_frames("pool_khmer"); ids_kh = km.predict(pool_kh)
pool_en, n_en = pool_frames("pool_english"); ids_en = km.predict(pool_en)
print(f"Khmer pool: {n_kh} clips, {len(pool_kh)} frames ({len(pool_kh)/100:.0f} s), {100*is_sil(ids_kh).mean():.0f} % quiet")
print(f"English pool: {n_en} clips, {len(pool_en)} frames ({len(pool_en)/100:.0f} s), {100*is_sil(ids_en).mean():.0f} % quiet")
# drop quiet frames from the pools and from the probe clips
pool_kh_v, ids_kh_v = pool_kh[~is_sil(ids_kh)], ids_kh[~is_sil(ids_kh)]
pool_en_v, ids_en_v = pool_en[~is_sil(ids_en)], ids_en[~is_sil(ids_en)]
nn_kh = NearestNeighbors(n_neighbors=4).fit(pool_kh_v)
nn_en = NearestNeighbors(n_neighbors=4).fit(pool_en_v)
d_kh, d_en, same_kh, same_en, per_clip = [], [], [], [], []
for i in range(48):
v = ~is_sil(labels[i]); f = mels_z[i][v]; l = labels[i][v]
dk, jk = nn_kh.kneighbors(f); de, je = nn_en.kneighbors(f)
d_kh.append(dk[:, 0]); d_en.append(de[:, 0])
same_kh.append((ids_kh_v[jk] == l[:, None]).mean(1)) # do the 4 neighbours have the SAME sound cluster?
same_en.append((ids_en_v[je] == l[:, None]).mean(1))
per_clip.append((probe_meta.utt_id[i], probe_meta.gender[i], dk[:, 0].mean(), de[:, 0].mean(), same_kh[-1].mean(), same_en[-1].mean()))
d_kh, d_en, same_kh, same_en = map(np.concatenate, (d_kh, d_en, same_kh, same_en))
far = np.percentile(d_kh, 95) # "far" = farther than 95 % of Khmer-pool matches
cov = pd.DataFrame(per_clip, columns=["utt_id", "gender", "mean_d_khmer", "mean_d_english", "same_sound_khmer", "same_sound_english"])
cov.to_csv(OUT / "pool_coverage.csv", index=False)
print(f"\nmedian NN distance: Khmer pool {np.median(d_kh):.2f} English pool {np.median(d_en):.2f}")
print(f"'far' frames (d > {far:.2f}): Khmer pool {100*(d_kh>far).mean():.1f} % English pool {100*(d_en>far).mean():.1f} %")
print(f"neighbours keep the sound: Khmer pool {100*same_kh.mean():.1f} % English pool {100*same_en.mean():.1f} % (4-NN same-cluster rate)")
print(cov.groupby("gender")[["mean_d_khmer", "mean_d_english", "same_sound_khmer", "same_sound_english"]].mean().round(3))
Khmer pool: 22 clips, 18208 frames (182 s), 26 % quiet English pool: 22 clips, 19869 frames (199 s), 33 % quiet
median NN distance: Khmer pool 4.28 English pool 4.31
'far' frames (d > 6.24): Khmer pool 5.0 % English pool 4.1 %
neighbours keep the sound: Khmer pool 64.0 % English pool 63.1 % (4-NN same-cluster rate)
mean_d_khmer mean_d_english same_sound_khmer same_sound_english
gender
female 4.361 4.415 0.617 0.612
male 4.524 4.544 0.668 0.655
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short compare how often each sound cluster is needed by the probe clips with how often each pool has it, and plot the distances and the coverage.
under: clusters where the pool has less than a quarter of the share that the probe clips need.- Left plot: histograms of the nearest-pool distance. Right plot: three bars per cluster: probe clips (blue, needed), Khmer pool (orange), English pool (green).
What the output means¶
In short English pool under-covers clusters [10, 3, 23, 5, 24] = 21 % of probe frames. Khmer pool under-covers [6, 3, 5, 26, 21, 0] = 18 %. The table lists the 8 most needed clusters.
- Both pools miss clusters 3 and 5 (each pool has almost none of them, though the probe clips use them).
- Cluster 10 (probe share 0.065): English pool 0.008 (about an eighth, ratio 0.13). Cluster 24: English 0.001 (ratio 0.04). Together these two clusters are 9.8 % of the probe frames (0.0654 + 0.0323 = 0.0977) (computed separately, not printed by the cells).
- English clusters with a big share and little need: 19 (0.172), 6 (0.141), 26 (0.090). The probe share of these three is 2.5 to 4 %.
# sound-cluster coverage: share of each (non-quiet) cluster in the probe clips vs in each pool
probe_ids = np.concatenate(labels[:48]); probe_ids = probe_ids[~is_sil(probe_ids)]
share = lambda ids: np.bincount(ids, minlength=K) / len(ids)
occ = pd.DataFrame({"probe": share(probe_ids), "khmer_pool": share(ids_kh_v), "english_pool": share(ids_en_v), "mean_dB": cluster_db.round(0)})
occ = occ[~occ.index.isin(SIL)].sort_values("probe", ascending=False)
occ["english/probe"] = (occ.english_pool / occ.probe).round(2); occ["khmer/probe"] = (occ.khmer_pool / occ.probe).round(2)
occ.to_csv(OUT / "pool_cluster_coverage.csv")
under = lambda col: occ[occ[col] < 0.25] # pool has < 1/4 of the share the probe needs
print(f"sounds the ENGLISH pool under-covers: clusters {list(under('english/probe').index)} = {100*under('english/probe').probe.sum():.0f} % of probe frames")
print(f"sounds the KHMER pool under-covers: clusters {list(under('khmer/probe').index)} = {100*under('khmer/probe').probe.sum():.0f} % of probe frames")
print(occ.head(8).round(3).to_string())
fig, ax = plt.subplots(1, 2, figsize=(12, 3.8), gridspec_kw={"width_ratios": [1, 1.6]})
bins = np.linspace(0, max(d_en.max(), d_kh.max()), 60)
ax[0].hist(d_kh, bins, alpha=0.6, label="Khmer pool (2 speakers)"); ax[0].hist(d_en, bins, alpha=0.6, label="English pool (1 speaker)")
ax[0].axvline(far, ls="--", color="k"); ax[0].set(title="nearest pool frame: distance", xlabel="distance (standardised log-mel units)", ylabel="probe frames"); ax[0].legend(fontsize=8)
x = np.arange(len(occ)); w = 0.28
ax[1].bar(x - w, occ.probe, w, label="Khmer probe clips (what is needed)")
ax[1].bar(x, occ.khmer_pool, w, label="Khmer pool"); ax[1].bar(x + w, occ.english_pool, w, label="English pool")
ax[1].set(xticks=x, xticklabels=occ.index, xlabel="sound cluster (sorted by how often the probe clips use it)", ylabel="share of non-quiet frames",
title="sound-cluster coverage: needed vs available in each pool")
ax[1].legend(fontsize=8); plt.tight_layout(); plt.savefig(OUT / "pool_coverage.png", dpi=110); plt.show()
sounds the ENGLISH pool under-covers: clusters [10, 3, 23, 5, 24] = 21 % of probe frames
sounds the KHMER pool under-covers: clusters [6, 3, 5, 26, 21, 0] = 18 % of probe frames
probe khmer_pool english_pool mean_dB english/probe khmer/probe
27 0.082 0.039 0.027 -49.0 0.33 0.48
2 0.067 0.096 0.034 -42.0 0.51 1.43
15 0.067 0.167 0.025 -59.0 0.38 2.50
10 0.065 0.054 0.008 -52.0 0.13 0.82
13 0.060 0.027 0.105 -38.0 1.75 0.46
20 0.055 0.087 0.046 -53.0 0.83 1.57
28 0.047 0.023 0.017 -57.0 0.37 0.49
11 0.044 0.014 0.040 -55.0 0.92 0.32
Additional Reading¶
How to read this plot¶
In short left: the two distance histograms sit almost on top of each other. Right: the two pools differ from the need in many clusters, in both directions. Counted over all 24 clusters, each pool is within 25 % of the need for only 3 to 4 clusters (computed separately, not printed by the cells). The two pools miss about the same share (21 % and 18 % of probe frames); neither is close to the need.
Left. For every Khmer probe frame: how far is its closest pool frame. The two histograms lie almost on top of each other (medians 4.28 and 4.31; 5.0 % and 4.1 % of frames beyond the dashed "far" line).
Right. One group of bars per sound cluster, sorted by how much the probe clips use it. Blue = need, orange = Khmer pool, green = English pool.
The two pools differ from the need in many clusters, in both directions. For example, clusters 15 (Khmer pool) and 19 (English pool) are over-covered by one pool, and clusters 10, 3, 5 and 24 are under-covered. Counted over all 24 clusters, each pool is within 25 % of the need for only 3 to 4 clusters (computed separately, not printed by the cells). The two pools are similar to each other in how much they miss (21 % and 18 % of probe frames). They are not close to the need.
- A tall blue bar with a tiny green bar is a sound the English pool barely has. In our run: clusters 10 and 24.
- A tall green bar with a small blue bar is an English sound that Khmer uses less: clusters 19, 6 and 26.
Be careful with the conclusion. On log-mel the pools look similar to each other (neither is close to the need). Each under-covers about a fifth of the probe frames (21 % and 18 %), and the 4-neighbour same-sound rate is about 64 % for both. The course gives three reasons. Each one is a lesson, and none was tested here (course explanation, not tested here):
- Log-mel distance is dominated by voice and channel, not by sound identity (section 4 had speaker probes at 98 % on log-mel). The course says the Khmer pool is two Digital Divide Data speakers on a different channel from the phone probe clips. To log-mel it is then almost as foreign as LibriSpeech (background fact from the course text, not checked here).
- 32 clusters on log-mel are coarse sound classes mixed with speaker traits. A Khmer-only vowel and an English vowel with the same rough spectral tilt can share a cluster.
- 18 000 to 20 000 pool frames cover an 80-d log-mel space densely, so some frame is always near. The question is whether it is the right sound, which log-mel cannot ask precisely.
So the measurement must be repeated on an SSL layer. The course says that there, same-sound frames of different speakers and channels sit together, and Khmer sounds with no English counterpart (unreleased final stops, the /ɲ/ nasal, long/short vowel contrasts) have no close neighbour in a LibriSpeech pool (background fact from the course text, not checked here). This is a good next step on a machine with internet (not run here).
The research point. The leaderboard of Day 7 (data/meta/leaderboard.csv, read separately, not by these cells) gives CER 27.55 % for knnvc_english_pool and 24.42 % for knnvc_khmer_pool (computed separately, not printed by the cells). The English pool is worse by about 3 points. Those pools are bigger than the ones here (the file says 40 English and 17 Khmer voices). The course explains the gap by WavLM-layer-6 frames of Khmer-only sounds being replaced by the nearest English sound (course explanation, not tested here). A pool must cover the source language's sounds before it can hide the source speaker (course explanation, not tested here). The course also says that a Khmer pool keeps the words but gives the attacker a smaller set of target voices (course explanation, not tested here).
Build: a layer-probing tool (src/probe.py)¶
The probing code of sections 4 and 5 lives in src/probe.py, so tomorrow's notebooks (and your own experiments) can probe any encoder in three lines. The file has six functions; the notebook imports five of them (the sixth, linear_cka, is used inside layer_similarity). It already exists in the project; this notebook imports it (in the first code cell) and does not rewrite it.
from src.probe import pool_features, probe_accuracy, probe_layers, layer_similarity, plot_layer_curves
m, s = probe_layers(layer_frames, labels=speaker_ids, groups=sentence_ids) # list over layers of list over clips of (T, D)
plot_layer_curves({"speaker": (m, s)}, chance={"spk": 1/8})
pool_features(frames_per_clip, stats=False): mean (optionally mean + std) pooling, one vector per clip;probe_accuracy(features, labels, groups): logistic regression withGroupKFold(leave-one-group-out when groups are few); returns the mean and the std over the folds;probe_layers(...): the same for every layer at once;linear_cka(X, Y): linear CKA between two layers; the helper used insidelayer_similarity(not imported here);layer_similarity(...): linear CKA matrix between layers;plot_layer_curves(...): the standard curve plot with chance lines.
What the next cell does. It runs the mini-model analysis again through the tool, now also with stats=True pooling, and compares it with the mean-only result of section 4. This is a comparison of two pooling choices, not a from-scratch vs tool check: the code of section 4 already used the tool. The check against real encoders is section 5, which could not run here.
Additional reading: you can read all of it or just read the "In short"¶
What this cell does¶
In short probe the mini model again with mean + std pooling, print a table next to the mean-only result, plot four curves, and list the files in outputs/day5/.
probe_layers(..., stats=True)appends the per-dimension std to the pooled vector (160 numbers instead of 80, or 256 instead of 128).- The table is saved to
outputs/day5/mini_probe_pooling_comparison.csv; the plot tomini_probe_tool.png. - The last line lists the 11 files in
outputs/day5/that the notebook has written.
What the output means¶
In short speaker accuracy 0.979 / 0.979 / 0.979 / 0.979 → 0.979 / 0.979 / 0.979 / 0.958 (mean → mean+std). Sentence accuracy 0.271 / 0.208 / 0.188 / 0.229 → 0.396 / 0.333 / 0.333 / 0.271.
- The speaker probe barely moves; only L2 drops by one clip (0.979 → 0.958).
- The sentence probe goes up at every layer: +0.125 (log-mel), +0.125 (L0), +0.146 (L1; 0.1458 before rounding, the table shows 0.333 and 0.188), +0.042 (L2) (computed separately, not printed by the cells).
- The 11 saved files are the pictures, the CSV tables and
mini_hubert.ptof this notebook.
spk_m2, spk_s2 = probe_layers(layer_frames, labels=spk, groups=sent, stats=True)
con_m2, con_s2 = probe_layers(layer_frames, labels=sent, groups=spk, stats=True)
cmp = pd.DataFrame({"layer": layer_names, "speaker_mean_pool": spk_m.round(3), "speaker_mean+std_pool": spk_m2.round(3),
"sentence_mean_pool": con_m.round(3), "sentence_mean+std_pool": con_m2.round(3)})
print(cmp.to_string(index=False))
cmp.to_csv(OUT / "mini_probe_pooling_comparison.csv", index=False)
plot_layer_curves({"speaker, mean pool": (spk_m, spk_s), "speaker, mean+std": (spk_m2, spk_s2),
"sentence, mean pool": (con_m, con_s), "sentence, mean+std": (con_m2, con_s2)},
chance={"spk": 1/8, "sent": 1/6}, title="src.probe on the mini model: pooling choice", layer_names=layer_names)
plt.tight_layout(); plt.savefig(OUT / "mini_probe_tool.png", dpi=110); plt.show()
print("saved outputs:", sorted(p.name for p in OUT.iterdir()))
layer speaker_mean_pool speaker_mean+std_pool sentence_mean_pool sentence_mean+std_pool
log-mel 0.979 0.979 0.271 0.396
L0 (proj. input) 0.979 0.979 0.208 0.333
L1 0.979 0.979 0.188 0.333
L2 0.979 0.958 0.229 0.271
saved outputs: ['kmeans_clusters.png', 'layer_pca_speaker_vs_sound.png', 'mini_hubert.pt', 'mini_hubert_loss.png', 'mini_probe_layers.png', 'mini_probe_pooling_comparison.csv', 'mini_probe_results.csv', 'mini_probe_tool.png', 'pool_cluster_coverage.csv', 'pool_coverage.csv', 'pool_coverage.png']
Additional Reading¶
How to read this plot¶
In short adding the std lifts the sentence probe at every layer (L2 least), the speaker probe stays at 0.98. The lift is in the pooling, not in new content in the model.
Four curves: speaker and sentence, each with mean pooling and with mean + std pooling. Two lines at the top (speaker, about 0.98), two lines low (sentence).
The probe.py docstring says adding the std usually helps the speaker probe, because a voice shows in the spread of a feature (course explanation, not tested here). Here the speaker was already at 0.979, so there is nothing to gain.
The sentence probe goes up from 0.19-0.27 to 0.27-0.40. The gain is biggest at L1 (+0.146; 0.1458 before rounding, the table shows 0.333 and 0.188), about the same at log-mel and L0 (+0.125), and small at L2 (+0.042). The gain at log-mel is about as big as the fold std of the mean-only probe (0.116) (computed separately, not printed by the cells).
This is not new content information from the model: log-mel gains just as much as L0 and L1. The std measures how much the loudness and the spectrum change over the clip, and this differs between the six sentences (course explanation, not tested here). When a pooling change lifts several layers by a similar amount, the gain is in the pooling, not in the layer. Compare against the fold std before you trust a 1-2 point difference between layers.
In the course plot the legend covers the L2 points and the two "chance" labels overlap each other. This is how the course cell draws it.