Comprehension Musicale avec MERT2 : Embeddings et Sondes Lineaires

Module : 04-Audio-Applications Niveau : Applications Technologies : MERT-v2-30s, MERT-v2-FullSong (m-a-p), PyTorch, scikit-learn, soundfile VRAM estimee : ~4 GB (632 M parametres en fp32, deux modeles charges sequentiellement) Duree estimee : 30 minutes

Licence des poids : MERT-v2-30s et MERT-v2-FullSong sont distribues sous CC BY-NC 4.0 (usage commercial interdit). Le code de la famille YuE2 est Apache 2.0, mais les poids telecharges depuis Hugging Face restent non commerciaux. Ce notebook l’utilise a des fins pedagogiques, conformement a la licence.

Objectifs d’Apprentissage

# Parametres Papermill - JAMAIS modifier ce commentaire

# Configuration notebook
notebook_mode = "interactive"        # "interactive" ou "batch"
skip_widgets = False                # True pour mode batch MCP
debug_level = "INFO"

# Parametres MERT2
mert2_model_id = "m-a-p/MERT-v2-30s"          # encodeur 30 s
mert2_fullsong_id = "m-a-p/MERT-v2-FullSong"  # variante morceaux complets (300 s)
device_override = "auto"                      # "auto", "cuda" ou "cpu"
corpus_dir = "output/mert2-corpus"            # repertoire de sortie des clips synthetises

Les parametres Papermill identifient les deux checkpoints MERT2 publies par l’equipe MAP (YuE2 family). corpus_dir recoit les clips synthetises : ce sont des artefacts d’execution, ils ne sont pas commités dans le depot.

# Setup environnement et verification
import os
import sys
import time
import gc
import json
import platform

import numpy as np
import soundfile as sf
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import LeaveOneOut

# Avertissements huggingface_hub : sans ces variables, le hub imprime des chemins
# machine absolus (cache, site-packages) dans les sorties de cellules.
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")

print("VERIFICATION DE L'ENVIRONNEMENT")
print("=" * 50)

# Pins recommandes par la carte modele MERT2 (quick start officiel)
try:
    import torch
    import torchaudio
    import transformers
    print(f"torch           {torch.__version__}")
    print(f"torchaudio      {torchaudio.__version__}")
    print(f"transformers    {transformers.__version__}")
    print(f"soundfile       {sf.__version__}")
    print(f"scikit-learn    {sklearn.__version__}")
    print(f"numpy           {np.__version__}")
except ImportError as exc:
    print(f"DEFAUT ENVIRONNEMENT : {exc}")
    print("Pins attendus (carte MERT2) : torch==2.6.0 torchaudio==2.6.0 transformers==4.53.2")
    print("Voir la cellule d'installation dans le README de la serie Audio (kernel mert2-gpu).")

device = device_override
if device == "auto":
    device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"device          {device}")
if device == "cuda":
    print(f"GPU             {torch.cuda.get_device_name(0)}")
    props = torch.cuda.get_device_properties(0)
    print(f"VRAM totale     {props.total_memory / 1024**3:.1f} GB")

os.makedirs(corpus_dir, exist_ok=True)
t_start = time.time()
print("\nEnvironnement pret. Poids MERT2 sous licence CC BY-NC 4.0 (usage commercial interdit).")
VERIFICATION DE L'ENVIRONNEMENT
==================================================
torch           2.6.0+cu126
torchaudio      2.6.0+cu126
transformers    4.53.2
soundfile       0.14.0
scikit-learn    1.9.1
numpy           2.5.3
device          cuda
GPU             NVIDIA GeForce RTX 3070 Laptop GPU
VRAM totale     8.0 GB

Environnement pret. Poids MERT2 sous licence CC BY-NC 4.0 (usage commercial interdit).

Interpretation : environnement

La carte modele MERT2 epingle torch==2.6.0, torchaudio==2.6.0 et transformers==4.53.2 : le code de modelisation (configuration_mert2.py, modeling_mert2.py) est telecharge depuis le repo Hugging Face (trust_remote_code=True) et cible ces versions. Le kernel mert2-gpu du depot porte exactement ces pins ; un environment avec un torch 2.13+ ne dispose d’aucun torchaudio compatible (la serie torchaudio s’arrete a 2.11), d’ou le venv dedie.

Section 1 : MERT2, un encodeur musical self-supervised

MERT2 est l’encodeur bidirectionnel de la famille YuE2 (MAP / HKUST / Tokenwave.AI / NYU / Stanford et al.). Ou YuE2-3B genere des chansons (voir le notebook 02-7), MERT2 comprend : pre-entraine de maniere self-supervised sur 30 s extraits (MERT-v2-30s) puis sur morceaux complets (MERT-v2-FullSong), il produit des representations a 25 Hz utilisables pour la classification, le retrieval, l’analyse musicologique - ou comme tokenizer de la branche causale de YuE2.

Caracteristiques (carte officielle) :

MERT-v2-30s MERT-v2-FullSong
Parametres 632 M 632 M
Entree 24 kHz mono, extraits <= 30 s 24 kHz mono, morceaux complets
Sortie frames 25 Hz x 1024 dims, 24 blocs idem, contexte long
Pre-entrainement extraits de 30 s continuation sur morceaux entiers
Licence poids CC BY-NC 4.0 CC BY-NC 4.0

Citations academiques : MERT (Li et al., ICLR 2024) et MARBLE (Yuan et al., NeurIPS 2023) - le rapport technique MERT2 est annonce “coming soon” a la date d’ecriture de ce notebook.

Le diagramme situe MERT2 dans la famille YuE2 - les deux branches du pre-entrainement bidirectionnel (30 s) et causal (tokenization pour la generation) :

flowchart LR
    subgraph pre["Pre-entrainement MERT2 (self-supervised)"]
        audio["Audio 24 kHz mono"] --> enc["Encodeur bidirectionnel<br/>632 M - 24 blocs - 1024 dims"]
        enc -->|"branche bidirectionnelle"| m30["MERT-v2-30s<br/>extraits 30 s"]
        enc -->|"continuation full-song"| mfs["MERT-v2-FullSong<br/>morceaux complets"]
    end
    subgraph yue2["Famille YuE2 (generation)"]
        m30 -->|"tokenizer causal"| yue["YuE2-3B<br/>AR-NAR MoT 3.59 B"]
        yue --> vae["YuE2-Vae 48 kHz stereo"]
        ss["SheetSage2<br/>MERT2-FS + LoRA + AR"] -->|"audio vers partition ABC"| yue
    end
    m30 --> emb["Embeddings<br/>similarite / retrieval / sondes"]
    mfs --> emb
    emb --> taches["Classification - retrieval<br/>conditionnement"]

Ce notebook exerce la voie embeddings ; la voie generation est couverte par 02-7 (YuE2).

# Chargement de MERT-v2-30s
from transformers import AutoFeatureExtractor, AutoModel

print("CHARGEMENT MERT-v2-30s")
print("=" * 50)
t0 = time.time()
processor = AutoFeatureExtractor.from_pretrained(mert2_model_id, trust_remote_code=True)
model_30s = AutoModel.from_pretrained(mert2_model_id, trust_remote_code=True).eval().to(device)
n_params = sum(p.numel() for p in model_30s.parameters())
print(f"Modele charge en {time.time() - t0:.1f} s")
print(f"Parametres    {n_params / 1e6:.0f} M")
print(f"Sampling rate {processor.sampling_rate} Hz (carte : 24 kHz)")
print(f"Couches       {getattr(model_30s.config, 'num_hidden_layers', '?')} blocs")
print(f"Dimension     {getattr(model_30s.config, 'hidden_size', '?')}")
if device == "cuda":
    print(f"VRAM allouee  {torch.cuda.memory_allocated() / 1024**3:.2f} GB (poids fp32)")
CHARGEMENT MERT-v2-30s
==================================================
Modele charge en 6.5 s
Parametres    632 M
Sampling rate 24000 Hz (carte : 24 kHz)
Couches       24 blocs
Dimension     1024
VRAM allouee  2.36 GB (poids fp32)

Interpretation : chargement

Le checkpoint telecharge depuis Hugging Face fait ~2.5 GB (632 M parametres en fp32). Sur un GPU 8 GB le modele tient largement ; il sera remplace par MERT-v2-FullSong (meme taille) en Section 5, les deux ne cohabitent jamais en VRAM.

Section 2 : Corpus synthetique a facteurs controles

Pour mesurer ce que l’embedding encode, il faut un corpus ou les facteurs de variation sont maitrises. Un corpus reel (GTZAN, MTG-Jamendo) melange instrumentation, mixage, masterisation - autant de facteurs impossibles a isoler. Ici, chaque clip est synthetise par le notebook selon un plan factoriel 3 motifs x 4 timbres x 2 transpositions = 24 clips :

  • Motif (contenu musical) : l’organisation intervallique - sauts majeurs, descente chromatique, arpege mineur. C’est l’identite “musicale” du clip, invariante par transposition.
  • Timbre (rendu) : la composition harmonique de chaque note - sinus pur, harmoniques impaires (clarinette), harmoniques completes (richarme), attaque pincee avec decroissance.
  • Transposition : +0 ou +2 demi-tons - la meme structure intervallique, hauteur decalee.

La question discriminante : un encodeur musical capte-t-il le motif (structure relationnelle, donc invariante par transposition et timbre) ou le timbre (signature spectrale immediate) ? Une sonde lineaire par facteur repond en Section 6.

# Synthese du corpus 3 x 4 x 2 = 24 clips de 10 s
SR = processor.sampling_rate
DUR = 10.0

MOTIFS = {
    "A": {"nom": "sauts majeurs",     "pitchs": [0, 2, 4, 7, 9, 12, 9, 7, 4, 2], "note_dur": 0.5},
    "B": {"nom": "descente chromatique", "pitchs": list(range(12, 2, -1)),         "note_dur": 0.5},
    "C": {"nom": "arpege mineur",     "pitchs": [0, 3, 7, 12, 3, 7, 12, 15],     "note_dur": 0.25},
}
TIMBRES = {
    "t1": {"nom": "sinus pur",     "harmoniques": [1],       "decay": None},
    "t2": {"nom": "clarinette",    "harmoniques": [1, 3, 5, 7], "decay": None},
    "t3": {"nom": "richarme",      "harmoniques": [1, 2, 3, 4, 5, 6, 7, 8], "decay": None},
    "t4": {"nom": "pince",         "harmoniques": [1, 2, 3, 4, 5, 6], "decay": 0.15},
}
TRANSPOS = {"tp0": 0, "tp2": 2}

def synthese_note(freq, dur, harmoniques, decay, sr):
    n = int(dur * sr)
    t = np.arange(n) / sr
    signal = np.zeros(n)
    for h in harmoniques:
        signal += (1.0 / h) * np.sin(2 * np.pi * freq * h * t)
    if decay is not None:
        signal = signal * np.exp(-t / decay)
    # enveloppe douce anti-clic (10 ms)
    env = np.ones(n)
    ramp = int(0.010 * sr)
    if decay is None:
        env[:ramp] = np.linspace(0, 1, ramp)
        env[-ramp:] = np.linspace(1, 0, ramp)
    else:
        env[:ramp] = np.linspace(0, 1, ramp)
    return signal * env

def synthese_clip(motif, timbre, transpo, sr):
    cfg_m, cfg_t = MOTIFS[motif], TIMBRES[timbre]
    total = np.zeros(int(DUR * sr))
    pos = 0.0
    while pos < DUR:
        for p in cfg_m["pitchs"]:
            if pos >= DUR:
                break
            freq = 220.0 * 2 ** ((p + TRANSPOS[transpo]) / 12)
            note = synthese_note(freq, cfg_m["note_dur"], cfg_t["harmoniques"], cfg_t["decay"], sr)
            i0 = int(pos * sr)
            i1 = min(i0 + len(note), len(total))
            total[i0:i1] += note[: i1 - i0]
            pos += cfg_m["note_dur"]
    total = total / (np.abs(total).max() + 1e-9) * 0.6
    return total.astype(np.float32)

clips = []   # liste de dicts : fichier, motif, timbre, transposition
for m in MOTIFS:
    for t in TIMBRES:
        for tp in TRANSPOS:
            wav = synthese_clip(m, t, tp, SR)
            fname = f"{m}_{t}_{tp}.wav"
            sf.write(os.path.join(corpus_dir, fname), wav, SR)
            clips.append({"fichier": fname, "motif": m, "timbre": t, "tp": tp, "samples": len(wav)})

# Medley 120 s pour MERT-v2-FullSong : les 12 clips tp0 dans un ordre fixe
ordre_medley = [c for c in clips if c["tp"] == "tp0"]
medley = np.concatenate([
    synthese_clip(c["motif"], c["timbre"], c["tp"], SR) for c in ordre_medley
])
sf.write(os.path.join(corpus_dir, "medley_120s.wav"), medley, SR)

print(f"CORPUS SYNTHETIQUE : {len(clips)} clips de {DUR:.0f} s a {SR} Hz + 1 medley de {len(medley) / SR:.0f} s")
print("=" * 60)
for c in clips[:8]:
    print(f"  {c['fichier']:14s} motif={c['motif']} ({MOTIFS[c['motif']]['nom']:22s}) timbre={c['timbre']} tp={c['tp']}")
print(f"  ... ({len(clips) - 8} autres clips)")
for i, c in enumerate(ordre_medley):
    print(f"  medley segment {i:2d} : {c['motif']}_{c['timbre']}")
CORPUS SYNTHETIQUE : 24 clips de 10 s a 24000 Hz + 1 medley de 120 s
============================================================
  A_t1_tp0.wav   motif=A (sauts majeurs         ) timbre=t1 tp=tp0
  A_t1_tp2.wav   motif=A (sauts majeurs         ) timbre=t1 tp=tp2
  A_t2_tp0.wav   motif=A (sauts majeurs         ) timbre=t2 tp=tp0
  A_t2_tp2.wav   motif=A (sauts majeurs         ) timbre=t2 tp=tp2
  A_t3_tp0.wav   motif=A (sauts majeurs         ) timbre=t3 tp=tp0
  A_t3_tp2.wav   motif=A (sauts majeurs         ) timbre=t3 tp=tp2
  A_t4_tp0.wav   motif=A (sauts majeurs         ) timbre=t4 tp=tp0
  A_t4_tp2.wav   motif=A (sauts majeurs         ) timbre=t4 tp=tp2
  ... (16 autres clips)
  medley segment  0 : A_t1
  medley segment  1 : A_t2
  medley segment  2 : A_t3
  medley segment  3 : A_t4
  medley segment  4 : B_t1
  medley segment  5 : B_t2
  medley segment  6 : B_t3
  medley segment  7 : B_t4
  medley segment  8 : C_t1
  medley segment  9 : C_t2
  medley segment 10 : C_t3
  medley segment 11 : C_t4

Interpretation : corpus controle

Chaque clip est deterministe (aucun aleatoire) : deux executions produisent des WAV byte-identiques. Le plan factoriel donne 8 clips par motif, 6 par timbre, 12 par transposition - la base des sondes de la Section 6. Le medley enchaine les 12 clips non transposes : il servira a mesurer la capacite de contexte long de MERT-v2-FullSong (Section 5).

Section 3 : Extraction des embeddings

Le modele produit, pour 10 s d’audio, une sequence de 250 frames a 25 Hz (10 s x 25 Hz), chacune de dimension 1024, plus les sorties intermediaires des 24 blocs (output_hidden_states=True). Pour obtenir un embedding par clip, on moyenne les frames valides (pooling masque) : le masque d’attention du processeur marque les frames remplies par le padding, il faut les exclure du calcul - sinon la longueur du clip fuit dans l’embedding.

On extrait ici le pooling pour chacune des 24 couches : la Section 6 compare la couche 1 (hidden_states[0], features acoustiques basses) a la couche 23 (hidden_states[22], recommandee par le guide officiel pour la classification de genre).

# Extraction des embeddings par couche (MERT-v2-30s)
N_COUCHES = getattr(model_30s.config, "num_hidden_layers", 24)

def extraire_embeddings(wav, modele):
    inputs = processor(wav, sampling_rate=SR, return_tensors="pt").to(device)
    with torch.inference_mode():
        out = modele(**inputs, output_hidden_states=True)
    mask = out.feature_attention_mask[..., None].float()          # [1, frames, 1]
    pool_par_couche = []
    for hs in out.hidden_states:                                   # 24 tenseurs [1, frames, 1024]
        pooled = (hs * mask).sum(1) / mask.sum(1).clamp_min(1)     # [1, 1024]
        pool_par_couche.append(pooled[0].cpu().numpy())
    n_frames = int(out.feature_attention_mask.sum().item())
    return np.stack(pool_par_couche), n_frames                    # [24, 1024], frames valides

t0 = time.time()
embeddings_par_couche = {}   # fichier -> [24 couches, 1024]
frames_counts = {}
for c in clips:
    wav, _ = sf.read(os.path.join(corpus_dir, c["fichier"]), dtype="float32")
    pool, nfr = extraire_embeddings(wav, model_30s)
    embeddings_par_couche[c["fichier"]] = pool
    frames_counts[c["fichier"]] = nfr

print(f"EXTRACTION MERT-v2-30s : {len(clips)} clips en {time.time() - t0:.1f} s")
print("=" * 50)
ex = clips[0]["fichier"]
print(f"Exemple {ex} : {frames_counts[ex]} frames valides (attendu {int(DUR * 25)})")
print(f"Forme par clip : {embeddings_par_couche[ex].shape} = [couches, dims]")
print(f"Norme L2 couche 1 : {np.linalg.norm(embeddings_par_couche[ex][0]):.2f} | couche 24 : {np.linalg.norm(embeddings_par_couche[ex][23]):.2f}")
EXTRACTION MERT-v2-30s : 24 clips en 3.6 s
==================================================
Exemple A_t1_tp0.wav : 250 frames valides (attendu 250)
Forme par clip : (24, 1024) = [couches, dims]
Norme L2 couche 1 : 28.69 | couche 24 : 14.90

Interpretation : extraction

250 frames valides pour 10 s confirme la cadence 25 Hz de la carte modele (le processeur peut ajouter des frames de padding selon la fenetre interne - le pooling masque les ignore). Les normes L2 par couche montrent que l’echelle des representations varie avec la profondeur : la similarite cosinus de la Section 4 normalise cet effet.

Section 4 : Similarite cosinus - que structure l’espace ?

La matrice de similarite 24 x 24 entre embeddings pooled de la derniere couche (bloc 24) revele la structure de l’espace appris. Trois hypotheses :

  1. l’espace est groupe par motif (l’encodeur a capture la structure musicale relationnelle) ;
  2. l’espace est groupe par timbre (l’encodeur reste domine par la signature spectrale) ;
  3. l’espace est groupe par transposition (l’encodeur code la hauteur absolue).

La metrique tranche : moyenne des similarites intra-groupe pour chaque facteur, comparee a la similarite moyenne entre clips sans lien.

# Matrice de similarite cosinus (couche 24) et metriques par facteur
fichiers = [c["fichier"] for c in clips]
labels = {c["fichier"]: c for c in clips}
X = np.stack([embeddings_par_couche[f][23] for f in fichiers])     # [24 clips, 1024]
Xn = X / np.linalg.norm(X, axis=1, keepdims=True)
S = Xn @ Xn.T                                                       # cosinus 24 x 24

def similarite_groupe(facteur):
    vals_intra, vals_cross = [], []
    for i in range(len(fichiers)):
        for j in range(len(fichiers)):
            if i == j:
                continue
            meme = labels[fichiers[i]][facteur] == labels[fichiers[j]][facteur]
            (vals_intra if meme else vals_cross).append(S[i, j])
    return np.mean(vals_intra), np.mean(vals_cross)

print("SIMILARITE COSINUS - COUCHE 24 (derniere)")
print("=" * 50)
for facteur, attendu in [("motif", "structure musicale"), ("timbre", "rendu spectral"), ("tp", "hauteur absolue")]:
    intra, cross = similarite_groupe(facteur)
    print(f"  intra-{facteur:7s} {intra:+.4f}   vs hors-groupe {cross:+.4f}   (delta {intra - cross:+.4f})")

# Heatmap textuelle compacte : initiale du motif, tri motif -> timbre -> tp
symbole = {"A": "a", "B": "b", "C": "c"}
print("\nHeatmap (caractere = motif du clip colonne, plus la valeur est haute plus les clips se ressemblent) :")
entete = "      " + "".join(f"{symbole[labels[f]['motif']]}" for f in fichiers)
print(entete)
for i, fi in enumerate(fichiers):
    ligne = "".join("#" if i == j else ("+" if S[i, j] > np.median(S) else ".") for j in range(len(fichiers)))
    print(f"  {symbole[labels[fi]['motif']]}   {ligne}")
SIMILARITE COSINUS - COUCHE 24 (derniere)
==================================================
  intra-motif   +0.8768   vs hors-groupe +0.8332   (delta +0.0437)
  intra-timbre  +0.9249   vs hors-groupe +0.8247   (delta +0.1002)
  intra-tp      +0.8435   vs hors-groupe +0.8492   (delta -0.0057)

Heatmap (caractere = motif du clip colonne, plus la valeur est haute plus les clips se ressemblent) :
      aaaaaaaabbbbbbbbcccccccc
  a   #+.+++++++......++......
  a   +#.++++++++++...++......
  a   ..#+++....++++....++....
  a   +++#++....++++....++....
  a   ++++#+++..++++++....++++
  a   +++++#++..++++++....++..
  a   ++..++#+....++++......++
  a   ++..+++#....++++......++
  b   ++......#+++....++......
  b   ++......+#++....++......
  b   .+++++..++#+++....++....
  b   .+++++..+++#++....++....
  b   .+++++++..++#+++....++..
  b   ..++++++..+++#++....++..
  b   ....++++....++#+......++
  b   ....++++....+++#......++
  c   ++......++......#+++++++
  c   ++......++......+#++++++
  c   ..++......++....++#++++.
  c   ..++......++....+++#++..
  c   ....++......++..++++#+++
  c   ....++......++..+++++#++
  c   ....+.++......+++++.++#+
  c   ....+.++......++++..+++#

Interpretation : structure de l’espace

Le facteur dont l’ecart intra/hors-groupe est le plus eleve domine la geometrie de la derniere couche. La heatmap textuelle rend la structure bloc-diagonale visible a l’oeil si ce facteur structure l’espace. Ce resultat est le point de depart des sondes de la Section 6 : une sonde lineaire sait-elle separer le facteur dominant ET le facteur secondaire ? La mesure rend trois ecarts intra/hors-groupe : timbre +0,1002, motif +0,0437, transposition -0,0057. Le timbre domine la dernière couche — plus du double du motif (0,1002 contre 2 × 0,0437 = 0,0874). La transposition est le seul delta négatif : deux clips transposés l’un de l’autre (0,8435 en intra-tp) se ressemblent MOINS que deux clips pris hors groupe (0,8492). L’espace de la couche 24 n’a pas d’axe de hauteur ; il a un axe de timbre, un axe faible de motif, et rien pour la transposition.

Exercice 1 : Pooling masque

Objectif : implementer le pooling masque sans utiliser le code de la Section 3.

Etape 1 : ecrire une fonction qui prend frames (tenseur [1, F, D]), mask ([1, F], 1 = frame valide, 0 = padding) et rend l embedding moyenne [D] en ignorant les frames invalides.

Indice : mask[..., None] broadcast le masque sur la dimension D ; penser a clamp_min pour eviter une division par zero sur un clip entieremenent padding.

# Exercice 1 : pooling masque - a completer
def pooling_masque(frames, mask):
    # TODO etudiant : moyenne des frames valides (mask == 1), forme de sortie [D]
    # Etape 1 : broadcast du masque  mask[..., None]
    # Etape 2 : somme ponderee puis division par le nombre de frames valides
    # Etape 3 : ne jamais diviser par zero (clamp_min(1))
    return None

# Validation attendue : pooling_masque(frames_test, mask_test) doit redonner
# les memes vecteurs que extraire_embeddings() sur le meme clip (test enonce,
# la correction est la cellule 13 - comparer au triplet (frames, mask, pool)).
print("Exercice a completer : implementer pooling_masque(frames, mask).")
Exercice a completer : implementer pooling_masque(frames, mask).

Section 5 : MERT-v2-30s vs MERT-v2-FullSong

Les deux checkpoints partagent la meme architecture ; FullSong poursuit le pre-entrainement sur des morceaux entiers. Deux questions mesurees :

  1. Geometrie : la similarite 24 x 24 de FullSong agree-t-elle avec celle du modele 30 s (correlation de Pearson sur le triangle superieur) ?
  2. Contexte long : sur le medley de 120 s, la frame FullSong au centre de chaque segment de 10 s retrouve-t-elle le clip correspondant (retrieval par plus proche voisins parmi les 24 embeddings FullSong) ? Le modele 30 s ne peut pas englutir les 120 s d’un seul tenant - c’est precisement la capacite que FullSong ajoute.
# MERT-v2-FullSong : geometrie comparee + retrieval sur medley 120 s
del model_30s
gc.collect()
if device == "cuda":
    torch.cuda.empty_cache()

t0 = time.time()
processor = AutoFeatureExtractor.from_pretrained(mert2_fullsong_id, trust_remote_code=True)
model_fs = AutoModel.from_pretrained(mert2_fullsong_id, trust_remote_code=True).eval().to(device)
print(f"FullSong charge en {time.time() - t0:.1f} s "
      f"({sum(p.numel() for p in model_fs.parameters()) / 1e6:.0f} M parametres)")

# 1. Embeddings des 24 clips avec FullSong
emb_fs = {}
for c in clips:
    wav, _ = sf.read(os.path.join(corpus_dir, c["fichier"]), dtype="float32")
    pool, _ = extraire_embeddings(wav, model_fs)
    emb_fs[c["fichier"]] = pool[23]                     # derniere couche

Xfs = np.stack([emb_fs[f] for f in fichiers])
Xfs_n = Xfs / np.linalg.norm(Xfs, axis=1, keepdims=True)
Sfs = Xfs_n @ Xfs_n.T

from scipy.stats import pearsonr
tri = np.triu_indices(len(fichiers), k=1)
r, pval = pearsonr(S[tri], Sfs[tri])
print(f"\nGeometrie : correlation Pearson des similarites 30s vs FullSong = {r:.3f} (p={pval:.1e})")

# 2. Retrieval sur le medley 120 s (contexte long, seule FullSong peut l'englutir)
with torch.inference_mode():
    inputs = processor(medley, sampling_rate=SR, return_tensors="pt").to(device)
    out = model_fs(**inputs, output_hidden_states=True)
frames_medley = out.hidden_states[23][0].cpu().numpy()          # [3000+, 1024]
print(f"Medley {len(medley) / SR:.0f} s -> {frames_medley.shape[0]} frames FullSong (25 Hz)")

hits = 0
print("\nRetrieval par segment (frame centrale du segment -> plus proche clip) :")
for i in range(len(ordre_medley)):
    centre = int((i + 0.5) * DUR * 25)
    v = frames_medley[centre] / np.linalg.norm(frames_medley[centre])
    sims = Xfs_n @ v
    best = int(np.argmax(sims))
    ok = fichiers[best] == ordre_medley[i]["fichier"]
    hits += ok
    print(f"  segment {i:2d} ({ordre_medley[i]['fichier']:10s}) -> predit {fichiers[best]:10s} "
          f"{'OK' if ok else 'exact non, motif=' + labels[fichiers[best]]['motif']}")
print(f"\nRetrieval exact : {hits}/{len(ordre_medley)} segments (hasard : ~{len(ordre_medley) / 24:.1f})")
FullSong charge en 3.9 s (632 M parametres)

Geometrie : correlation Pearson des similarites 30s vs FullSong = 0.970 (p=0.0e+00)
Medley 120 s -> 3000 frames FullSong (25 Hz)

Retrieval par segment (frame centrale du segment -> plus proche clip) :
  segment  0 (A_t1_tp0.wav) -> predit A_t1_tp2.wav exact non, motif=A
  segment  1 (A_t2_tp0.wav) -> predit A_t3_tp0.wav exact non, motif=A
  segment  2 (A_t3_tp0.wav) -> predit A_t3_tp0.wav OK
  segment  3 (A_t4_tp0.wav) -> predit C_t4_tp0.wav exact non, motif=C
  segment  4 (B_t1_tp0.wav) -> predit C_t1_tp0.wav exact non, motif=C
  segment  5 (B_t2_tp0.wav) -> predit C_t2_tp0.wav exact non, motif=C
  segment  6 (B_t3_tp0.wav) -> predit C_t3_tp0.wav exact non, motif=C
  segment  7 (B_t4_tp0.wav) -> predit C_t4_tp0.wav exact non, motif=C
  segment  8 (C_t1_tp0.wav) -> predit C_t1_tp0.wav OK
  segment  9 (C_t2_tp0.wav) -> predit C_t2_tp0.wav OK
  segment 10 (C_t3_tp0.wav) -> predit C_t3_tp0.wav OK
  segment 11 (C_t4_tp0.wav) -> predit C_t4_tp0.wav OK

Retrieval exact : 5/12 segments (hasard : ~0.5)

Interpretation : FullSong

La correlation elevee entre les deux matrices de similarite indique que la continuation de pre-entrainement preserve la geometrie sur extraits courts. Le retrieval par segment mesure la capacite de contexte long : une frame au centre d’un segment de 10 s, extraite d’un passage de 120 s vu d’un seul tenant, doit rester proche du clip correspondant. Les segments faux sont instructifs : regarder si le clip predit partage au moins le motif (structure conservee, timbre confondu) ou aucun facteur. Le run concret sur les 12 segments du medley donne : exact 5/12 (rendu par la sortie, hasard ~0,5), motif conservé 7/12 — les trois A des segments 0 à 2 restent A et les quatre C restent C, seul le A du segment 3 déraille — et timbre conservé 11/12 : seul le segment 1 perd son t2 pour un t3. Et les quatre segments B sont TOUS prédits C, jamais A : une frame centrale porte le timbre (signal instantané) mais pas la descente chromatique du motif B (structure temporelle). La corrélation 0,970 des géométries globales ne descend pas intacte à l’échelle de la frame isolée.

Section 6 : Sondes lineaires - contenu vs rendu

Une sonde lineaire (logistic regression sur embeddings geles) mesure la linearite de l’information dans la representation : si un plan separe les classes, l’information y est lineairement accessible. Le protocole :

  • validation croisee leave-one-out (24 entrainements par configuration, chaque clip sert une fois de test) - le seul protocole honnete a cette taille de corpus ;
  • deux taches : motif (3 classes - contenu musical, invariante timbre/transposition) et timbre (4 classes - rendu spectral) ;
  • trois couches : L1 (hidden_states[0], bas niveau), L23 (hidden_states[22], couche recommandee par le guide officiel MERT2 pour le genre), L24 (derniere).

Avertissement methodologique : ce corpus synthetique de 24 clips est un proxy pedagogique, pas une reproduction du benchmark MARBLE (GTZAN genre accuracy 91.72 pour MERT-v2-30s, carte officielle). Le claim MARBLE n’est ni reproduit ni infirme ici - il est rappele comme reference de l’echelle published, la sonde locale mesure la separation de facteurs sur un corpus controle.

# Sondes lineaires LOO : motif (3 classes) et timbre (4 classes) x couches L1/L23/L24
couches_test = {"L1": 0, "L23": 22, "L24": 23}
y_motif = np.array([labels[f]["motif"] for f in fichiers])
y_timbre = np.array([labels[f]["timbre"] for f in fichiers])

def sonde_loo(idx_couche, y):
    Xc = np.stack([embeddings_par_couche[f][idx_couche] for f in fichiers])
    y_pred = np.empty(len(y), dtype=object)
    for tr, te in LeaveOneOut().split(Xc):
        clf = LogisticRegression(max_iter=2000, C=1.0)
        clf.fit(Xc[tr], y[tr])
        y_pred[te] = clf.predict(Xc[te])[0]
    return float(np.mean(y_pred == y))

print("SONDES LINEAIRES (leave-one-out, embeddings geles MERT-v2-30s)")
print("=" * 60)
print(f"{'couche':8s} {'motif (3 cl., hasard 0.33)':>28s} {'timbre (4 cl., hasard 0.25)':>30s}")
for nom, idx in couches_test.items():
    acc_m = sonde_loo(idx, y_motif)
    acc_t = sonde_loo(idx, y_timbre)
    print(f"{nom:8s} {acc_m:28.3f} {acc_t:30.3f}")
print("\nGuide officiel MERT2 (carte HF) : tache genre -> couche L23, learning rate 5e-3.")
SONDES LINEAIRES (leave-one-out, embeddings geles MERT-v2-30s)
============================================================
couche     motif (3 cl., hasard 0.33)    timbre (4 cl., hasard 0.25)
L1                              0.917                          1.000
L23                             1.000                          1.000
L24                             1.000                          1.000

Guide officiel MERT2 (carte HF) : tache genre -> couche L23, learning rate 5e-3.

Interpretation : sondes

Lecture attendue : la tache timbre (signature spectrale) est souvent lineairement accessible des les premieres couches ; la tache motif (structure relationnelle invariante par timbre et transposition) exige des couches profondes - c’est le motif du guide officiel (L23 pour le genre). Si la sonde motif reste proche du hasard a toutes les couches, la conclusion honnete est que la structure intervallique fine n’est pas lineairement codable dans cet espace - pas que le modele est “mauvais” : le benchmark MARBLE mesure des taches plus larges (genre, tonalite, emotions) sur des corpus reels ordres de grandeur plus grands.

Exercice 2 : Retrieval top-k

Objectif : implementer la recherche des k clips les plus similaires a une requete.

Etape 1 : ecrire top_k_similaires(embeddings, query_idx, k) qui rend les indices des k clips les plus proches (cosinus) de embeddings[query_idx], en excluant la requete elle-meme.

Indice : normaliser les lignes puis un produit matriciel donne toutes les similarites d’un coup ; np.argsort trie - attention a exclure l’indice de la requete avant de prendre les k premiers.

# Exercice 2 : retrieval top-k - a completer
def top_k_similaires(embeddings, query_idx, k=3):
    # TODO etudiant : indices des k plus proches voisins cosinus de embeddings[query_idx]
    # Etape 1 : normaliser chaque ligne (norme L2)
    # Etape 2 : similarites = produit matriciel avec la requete normalisee
    # Etape 3 : trier, exclure query_idx, garder les k premiers
    return None

# Validation attendue : top_k_similaires(X, 0, 3) doit rendre 3 indices dont les
# clips partagent le facteur dominant releve en Section 4 (verifier avec labels).
print("Exercice a completer : implementer top_k_similaires(embeddings, query_idx, k).")
Exercice a completer : implementer top_k_similaires(embeddings, query_idx, k).

Exercice 3 : Sonde sur d’autres couches

Objectif : etendre le balayage de la Section 6 aux couches intermediaires.

Etape 1 : ecrire accuracy_sonde(idx_couche) qui rend l’accuracy LOO de la tache motif a la couche demandee (reutiliser sonde_loo).

Etape 2 : balayer L8 (indice 7) et L16 (indice 15), comparer a L1/L23/L24 et situer le palier ou l’information de motif devient lineairement accessible.

# Exercice 3 : sonde multicouche - a completer
def accuracy_sonde(idx_couche):
    # TODO etudiant : accuracy LOO de la tache motif a la couche idx_couche
    # Etape 1 : appeler sonde_loo(idx_couche, y_motif)
    return None

# Validation attendue : accuracy_sonde(22) doit redonner la valeur L23 de la Section 6.
for nom, idx in [("L8", 7), ("L16", 15)]:
    print(f"Exercice a completer : accuracy_sonde({idx}) pour la couche {nom}.")
Exercice a completer : accuracy_sonde(7) pour la couche L8.
Exercice a completer : accuracy_sonde(15) pour la couche L16.
# Statistiques de session
print("STATISTIQUES DE SESSION")
print("=" * 50)
print(f"Date                {time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"Kernel              mert2-gpu (torch 2.6.0 / torchaudio 2.6.0 / transformers 4.53.2)")
print(f"Device              {device}" + (f" ({torch.cuda.get_device_name(0)})" if device == "cuda" else ""))
print(f"Modeles             {mert2_model_id} + {mert2_fullsong_id} (632 M chacun, charges sequentiellement)")
print(f"Corpus              {len(clips)} clips synthetiques {DUR:.0f} s + medley {len(medley) / SR:.0f} s")
print(f"Embeddings          {len(fichiers)} clips x 24 couches x {embeddings_par_couche[fichiers[0]].shape[1]} dims")
print(f"Duree totale        {time.time() - t_start:.0f} s")
print(f"Licence poids       CC BY-NC 4.0 (usage commercial interdit)")
STATISTIQUES DE SESSION
==================================================
Date                2026-09-13 03:42:50
Kernel              mert2-gpu (torch 2.6.0 / torchaudio 2.6.0 / transformers 4.53.2)
Device              cuda (NVIDIA GeForce RTX 3070 Laptop GPU)
Modeles             m-a-p/MERT-v2-30s + m-a-p/MERT-v2-FullSong (632 M chacun, charges sequentiellement)
Corpus              24 clips synthetiques 10 s + medley 120 s
Embeddings          24 clips x 24 couches x 1024 dims
Duree totale        33 s
Licence poids       CC BY-NC 4.0 (usage commercial interdit)

Verdict SOTA

Axe Verdict Preuve
Chargement des vrais poids SOTA-OK m-a-p/MERT-v2-30s et m-a-p/MERT-v2-FullSong charges firsthand depuis Hugging Face (trust_remote_code), 632 M parametres chacun, execution GPU locale
Execution SOTA-OK outputs reels commités (execution complete, 0 erreur) sur GPU local 8 GB, venv dedie aux pins de la carte (torch 2.6.0 / torchaudio 2.6.0 / transformers 4.53.2)
Claims MARBLE (91.72 genre GTZAN etc.) rappeles, non reproduits proxy pedagogique n=24 synthetique - le benchmark MARBLE exige corpus reels et protocole complet ; ce notebook mesure la separation de facteurs controles, pas le score published
Licence CC BY-NC 4.0 sur les poids rappele en tete de notebook et en statistiques de session - usage commercial interdit

Aucun verdict INTRINSIC n’est invoque : la checklist 6 axes ne s’applique pas (l’outil reel est installe et execute).


Synthese du module

Ce notebook a mesure ce qu’un encodeur musical self-supervised de 632 M parametres encode vraiment, sur un corpus a facteurs controles :

  1. Extraction : frames 25 Hz x 1024 dims, pooling masque par clip, pour les 24 couches
  2. Geometrie : la matrice de similarite cosinus identifie le facteur dominant de l’espace
  3. Contexte long : MERT-v2-FullSong englue 120 s d’un tenant et retrouve les segments par frame
  4. Sondes lineaires : timbre vs motif en LOO sur couches L1/L23/L24, confrontees au guide officiel des couches MERT2

Pour aller plus loin : le notebook 02-7 (YuE2) couvre la branche generation de la famille ; SheetSage2 (MERT2-FS + LoRA + AR) transcrit l’audio en partition ABC - l’espace d’embeddings mesure ici est le socle des deux.

Retour : README de la serie Audio

Retour au sommet