AMD telephony model: v5b_full + benchmarked v4a, code, model card
Browse files- README.md +81 -0
- benchmarked_v4a/v4a_ftw_mfnone_pw05.head.pt +3 -0
- benchmarked_v4a/v4a_ftw_mfnone_pw05.json +16 -0
- benchmarked_v4a/v4a_ftw_mfnone_pw05.yamnet.pth +3 -0
- code/extract_embeddings.py +449 -0
- code/predict_example.py +56 -0
- code/sweep_head.py +124 -0
- v5b_full.head.pt +3 -0
- v5b_full.json +16 -0
- v5b_full.yamnet.pth +3 -0
README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: cc-by-nc-4.0
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
pipeline_tag: audio-classification
|
| 6 |
+
tags:
|
| 7 |
+
- audio
|
| 8 |
+
- telephony
|
| 9 |
+
- answering-machine-detection
|
| 10 |
+
- voicemail-detection
|
| 11 |
+
- yamnet
|
| 12 |
+
- gru
|
| 13 |
+
- 8khz
|
| 14 |
+
- vicidial
|
| 15 |
+
- asterisk
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# AMD — Answering Machine Detection for telephony (YAMNet + GRU)
|
| 19 |
+
|
| 20 |
+
Real-time answering-machine detection for 8 kHz US-English telephony
|
| 21 |
+
(VICIdial / Asterisk outbound campaigns). A pure-PyTorch YAMNet backbone
|
| 22 |
+
(fine-tuned, top-2 blocks) feeds a bidirectional GRU head; output is
|
| 23 |
+
`p_machine ∈ [0,1]`. **~16 ms inference per 4 s of audio on a single CPU
|
| 24 |
+
thread** — fast enough to decide inside the first ring seconds.
|
| 25 |
+
|
| 26 |
+
Two checkpoints:
|
| 27 |
+
|
| 28 |
+
| checkpoint | training pool | how it was measured |
|
| 29 |
+
|---|---|---|
|
| 30 |
+
| **`v5b_full` (root)** | 50,876 clips — the full max-data pool (incl. real carrier voicemail, IVR, network intercepts, robocalls, call-center + conversational telephone speech) | validation (4,949 held-out clips) |
|
| 31 |
+
| `benchmarked_v4a/` | 31,947 clips (frozen-eval-disjoint) | two frozen external test sets |
|
| 32 |
+
|
| 33 |
+
## Results
|
| 34 |
+
|
| 35 |
+
`v5b_full` (validation, threshold 0.5): **97.7% accuracy**, machine recall
|
| 36 |
+
**97.0%** under a ≤3% false-machine-on-humans cap, fmh 1.56%. On held-out
|
| 37 |
+
clips of the hardest class — real carrier voicemail greetings — error is
|
| 38 |
+
**13%**, versus 91% for a naive-composition baseline.
|
| 39 |
+
|
| 40 |
+
`benchmarked_v4a` (frozen external sets, never trained on, one pass): 79.8%
|
| 41 |
+
accuracy / balanced-acc .845 / AUC .935 on a 796-clip external set;
|
| 42 |
+
92.5% / .928 / .979 on a 1,429-clip balanced set; machine recall at the
|
| 43 |
+
FTC-style ≤3% abandonment cap: 0.56–0.91.
|
| 44 |
+
|
| 45 |
+
## Usage
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
pip install torch numpy soundfile scipy
|
| 49 |
+
python code/predict_example.py your_call.wav
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
```python
|
| 53 |
+
import sys; sys.path.insert(0, "code")
|
| 54 |
+
from predict_example import load, predict
|
| 55 |
+
yamnet, head = load()
|
| 56 |
+
p_machine = predict(yamnet, head, "call.wav") # 0..1
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Audio: any wav (resampled to 16 kHz mono internally; trained domain is 8 kHz
|
| 60 |
+
G.711 telephony, first ≤10 s of the call).
|
| 61 |
+
|
| 62 |
+
## Intended use & limitations
|
| 63 |
+
|
| 64 |
+
- **Research / evaluation only (CC BY-NC 4.0).** Trained on a mixed-license
|
| 65 |
+
research pool; not cleared for commercial deployment.
|
| 66 |
+
- US-English telephony. Non-English and non-telephone audio are out of domain.
|
| 67 |
+
- Decision thresholds must be **calibrated on your own audio** — false-machine
|
| 68 |
+
rates measured on any fixed test set do not transfer exactly to live traffic.
|
| 69 |
+
- `v5b_full` has no fully-external benchmark (its pool absorbed the project's
|
| 70 |
+
test sources — by design, for maximum coverage); `benchmarked_v4a` is the
|
| 71 |
+
checkpoint with honest external numbers. Known weak spots: robocall recall
|
| 72 |
+
(v5 val slice: 26% miss), heavily accented/studio-recorded humans.
|
| 73 |
+
|
| 74 |
+
## Architecture
|
| 75 |
+
|
| 76 |
+
- Backbone: YAMNet (AudioSet), pure-PyTorch port, custom `torch.stft` mel
|
| 77 |
+
frontend — no TensorFlow; top-2 blocks fine-tuned (BN frozen), bf16.
|
| 78 |
+
- Head: bidirectional GRU over 1024-d patch embeddings (0.96 s window /
|
| 79 |
+
0.48 s hop), ~350k params, label smoothing 0.1.
|
| 80 |
+
- Files: `*.yamnet.pth` (backbone, 15 MB) + `*.head.pt` (head + config, 1 MB)
|
| 81 |
+
+ `*.json` (export manifest with round-trip verification).
|
benchmarked_v4a/v4a_ftw_mfnone_pw05.head.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fced3ef1ec2b9ad9c0717589341049751534040499aa2a9bcecc365b0107fd85
|
| 3 |
+
size 1051377
|
benchmarked_v4a/v4a_ftw_mfnone_pw05.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backbone_weights": "/root/amd/tracks/yamnet/experiments/backbone/v4a_ftw_mfnone_pw05.yamnet.pth",
|
| 3 |
+
"head_weights": "/root/amd/tracks/yamnet/experiments/backbone/v4a_ftw_mfnone_pw05.head.pt",
|
| 4 |
+
"head_config": {
|
| 5 |
+
"proj_dim": 128,
|
| 6 |
+
"hidden": 96,
|
| 7 |
+
"num_layers": 1,
|
| 8 |
+
"bidirectional": true,
|
| 9 |
+
"pooling": "last",
|
| 10 |
+
"dropout": 0.0
|
| 11 |
+
},
|
| 12 |
+
"gate_dbfs": -300.0,
|
| 13 |
+
"decision_threshold": 0.5,
|
| 14 |
+
"unfreeze_blocks": 2,
|
| 15 |
+
"note": "load backbone via extract_embeddings.load_model(backbone_weights); build head via sweep_head.build_config_head(head_config)."
|
| 16 |
+
}
|
benchmarked_v4a/v4a_ftw_mfnone_pw05.yamnet.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bdadf83b44b5318354aae774ae2d5ddec78aec21fe49205fb5135249b8814c8
|
| 3 |
+
size 15103509
|
code/extract_embeddings.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""YAMNet 1024-d embedding extractor for the AMD Track-3 (YAMNet + GRU) recipe.
|
| 3 |
+
|
| 4 |
+
Provenance / port choice
|
| 5 |
+
-------------------------
|
| 6 |
+
Network + pretrained weights: w-hc/torch_audioset (pure-PyTorch YAMNet port,
|
| 7 |
+
numerically validated against Google's TF YAMNet to atol=1e-6 on the CNN given
|
| 8 |
+
identical mel patches).
|
| 9 |
+
- architecture ported verbatim from
|
| 10 |
+
https://github.com/w-hc/torch_audioset/blob/master/torch_audioset/yamnet/model.py
|
| 11 |
+
- weights: https://github.com/w-hc/torch_audioset/releases/download/v0.1/yamnet.pth
|
| 12 |
+
sha256 620b5feb079a97f7c884427f66cf9b281dca0725f46828eb019d01cacb7ae968
|
| 13 |
+
(3.76M params; local copy at tracks/yamnet/weights/yamnet.pth)
|
| 14 |
+
|
| 15 |
+
Why we DON'T use the upstream frontend / torch-vggish-yamnet pip:
|
| 16 |
+
both pull in `torchaudio` for the log-mel, and there is no torchaudio build
|
| 17 |
+
matching this box's torch 2.13.0+cpu (ABI risk). So the log-mel frontend here
|
| 18 |
+
is reimplemented with CORE torch (`torch.stft` + a hand-built HTK mel
|
| 19 |
+
filterbank) reproducing w-hc's exact YAMNetParams. Zero pip installs.
|
| 20 |
+
Exact bit-parity with TF is not required: the GRU head is trained on and
|
| 21 |
+
evaluated with THESE embeddings, so only self-consistency matters — but the
|
| 22 |
+
frontend still matches YAMNet's documented params closely so the pretrained
|
| 23 |
+
conv weights stay meaningful.
|
| 24 |
+
|
| 25 |
+
Output
|
| 26 |
+
------
|
| 27 |
+
Per clip, one .npz with:
|
| 28 |
+
embeddings : float32 [n_frames, 1024] (0.96s window, 0.48s hop → ~1 vec/0.48s)
|
| 29 |
+
energy : float32 [n_frames] (dBFS of the raw 16k waveform slice
|
| 30 |
+
aligned to each patch; for the silence
|
| 31 |
+
gate, tuned LATER without re-extraction)
|
| 32 |
+
sr, n_frames scalars for provenance.
|
| 33 |
+
|
| 34 |
+
Usage
|
| 35 |
+
-----
|
| 36 |
+
python extract_embeddings.py --wav clip.wav --out-dir emb/
|
| 37 |
+
python extract_embeddings.py --manifest data/manifest.csv --out-dir emb/ --threads 8
|
| 38 |
+
python extract_embeddings.py --selftest # synthetic verify + benchmark
|
| 39 |
+
"""
|
| 40 |
+
import argparse
|
| 41 |
+
import csv
|
| 42 |
+
import hashlib
|
| 43 |
+
import math
|
| 44 |
+
import os
|
| 45 |
+
import sys
|
| 46 |
+
import time
|
| 47 |
+
|
| 48 |
+
import numpy as np
|
| 49 |
+
import soundfile as sf
|
| 50 |
+
import torch
|
| 51 |
+
import torch.nn as nn
|
| 52 |
+
import torch.nn.functional as F
|
| 53 |
+
from scipy.signal import resample_poly
|
| 54 |
+
|
| 55 |
+
# ----------------------------------------------------------------------------
|
| 56 |
+
# YAMNet params (from w-hc CommonParams/YAMNetParams == TF yamnet/params.py)
|
| 57 |
+
# ----------------------------------------------------------------------------
|
| 58 |
+
SR = 16000
|
| 59 |
+
STFT_WIN_S = 0.025 # 400 samples
|
| 60 |
+
STFT_HOP_S = 0.010 # 160 samples
|
| 61 |
+
N_FFT = 512
|
| 62 |
+
N_MELS = 64
|
| 63 |
+
MEL_MIN_HZ = 125.0
|
| 64 |
+
MEL_MAX_HZ = 7500.0
|
| 65 |
+
LOG_OFFSET = 0.001
|
| 66 |
+
PATCH_WINDOW_S = 0.96 # 96 mel frames
|
| 67 |
+
PATCH_HOP_S = 0.48 # 48 mel frames -> one embedding every 0.48s
|
| 68 |
+
EMB_DIM = 1024
|
| 69 |
+
|
| 70 |
+
WIN_SAMPLES = int(round(SR * STFT_WIN_S)) # 400
|
| 71 |
+
HOP_SAMPLES = int(round(SR * STFT_HOP_S)) # 160
|
| 72 |
+
PATCH_FRAMES = int(round(PATCH_WINDOW_S / STFT_HOP_S)) # 96
|
| 73 |
+
PATCH_HOP_FRAMES = int(round(PATCH_HOP_S / STFT_HOP_S)) # 48
|
| 74 |
+
|
| 75 |
+
_WEIGHTS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
| 76 |
+
"weights", "yamnet.pth")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ----------------------------------------------------------------------------
|
| 80 |
+
# Network (ported verbatim from w-hc/torch_audioset yamnet/model.py)
|
| 81 |
+
# ----------------------------------------------------------------------------
|
| 82 |
+
class Conv2d_tf(nn.Conv2d):
|
| 83 |
+
"""Conv2d with TF-Slim 'SAME' padding."""
|
| 84 |
+
def __init__(self, *args, **kwargs):
|
| 85 |
+
padding = kwargs.pop("padding", "SAME")
|
| 86 |
+
super().__init__(*args, **kwargs)
|
| 87 |
+
self.padding = padding
|
| 88 |
+
assert self.padding == "SAME"
|
| 89 |
+
|
| 90 |
+
def _same_pad(self, input, dim):
|
| 91 |
+
input_size = input.size(dim + 2)
|
| 92 |
+
filter_size = self.kernel_size[dim]
|
| 93 |
+
dilate = self.dilation if isinstance(self.dilation, int) else self.dilation[dim]
|
| 94 |
+
stride = self.stride if isinstance(self.stride, int) else self.stride[dim]
|
| 95 |
+
eff = (filter_size - 1) * dilate + 1
|
| 96 |
+
out_size = (input_size + stride - 1) // stride
|
| 97 |
+
total = max(0, (out_size - 1) * stride + eff - input_size)
|
| 98 |
+
return int(total % 2 != 0), total
|
| 99 |
+
|
| 100 |
+
def forward(self, input):
|
| 101 |
+
odd_1, pad_1 = self._same_pad(input, dim=0)
|
| 102 |
+
odd_2, pad_2 = self._same_pad(input, dim=1)
|
| 103 |
+
if odd_1 or odd_2:
|
| 104 |
+
input = F.pad(input, [0, odd_2, 0, odd_1])
|
| 105 |
+
return F.conv2d(input, self.weight, self.bias, self.stride,
|
| 106 |
+
padding=[pad_1 // 2, pad_2 // 2],
|
| 107 |
+
dilation=self.dilation, groups=self.groups)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class CONV_BN_RELU(nn.Module):
|
| 111 |
+
def __init__(self, conv):
|
| 112 |
+
super().__init__()
|
| 113 |
+
self.conv = conv
|
| 114 |
+
self.bn = nn.BatchNorm2d(conv.out_channels, eps=1e-4)
|
| 115 |
+
self.relu = nn.ReLU()
|
| 116 |
+
|
| 117 |
+
def forward(self, x):
|
| 118 |
+
return self.relu(self.bn(self.conv(x)))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class Conv(nn.Module):
|
| 122 |
+
def __init__(self, kernel, stride, input_dim, output_dim):
|
| 123 |
+
super().__init__()
|
| 124 |
+
self.fused = CONV_BN_RELU(Conv2d_tf(
|
| 125 |
+
in_channels=input_dim, out_channels=output_dim, kernel_size=kernel,
|
| 126 |
+
stride=stride, padding="SAME", bias=False))
|
| 127 |
+
|
| 128 |
+
def forward(self, x):
|
| 129 |
+
return self.fused(x)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class SeparableConv(nn.Module):
|
| 133 |
+
def __init__(self, kernel, stride, input_dim, output_dim):
|
| 134 |
+
super().__init__()
|
| 135 |
+
self.depthwise_conv = CONV_BN_RELU(Conv2d_tf(
|
| 136 |
+
in_channels=input_dim, out_channels=input_dim, groups=input_dim,
|
| 137 |
+
kernel_size=kernel, stride=stride, padding="SAME", bias=False))
|
| 138 |
+
self.pointwise_conv = CONV_BN_RELU(Conv2d_tf(
|
| 139 |
+
in_channels=input_dim, out_channels=output_dim, kernel_size=1,
|
| 140 |
+
stride=1, padding="SAME", bias=False))
|
| 141 |
+
|
| 142 |
+
def forward(self, x):
|
| 143 |
+
return self.pointwise_conv(self.depthwise_conv(x))
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class YAMNet(nn.Module):
|
| 147 |
+
_CFG = [
|
| 148 |
+
(Conv, [3, 3], 2, 32), (SeparableConv, [3, 3], 1, 64),
|
| 149 |
+
(SeparableConv, [3, 3], 2, 128), (SeparableConv, [3, 3], 1, 128),
|
| 150 |
+
(SeparableConv, [3, 3], 2, 256), (SeparableConv, [3, 3], 1, 256),
|
| 151 |
+
(SeparableConv, [3, 3], 2, 512), (SeparableConv, [3, 3], 1, 512),
|
| 152 |
+
(SeparableConv, [3, 3], 1, 512), (SeparableConv, [3, 3], 1, 512),
|
| 153 |
+
(SeparableConv, [3, 3], 1, 512), (SeparableConv, [3, 3], 1, 512),
|
| 154 |
+
(SeparableConv, [3, 3], 2, 1024), (SeparableConv, [3, 3], 1, 1024),
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
def __init__(self):
|
| 158 |
+
super().__init__()
|
| 159 |
+
input_dim = 1
|
| 160 |
+
self.layer_names = []
|
| 161 |
+
for i, (mod, kernel, stride, out_dim) in enumerate(self._CFG):
|
| 162 |
+
name = f"layer{i + 1}"
|
| 163 |
+
self.add_module(name, mod(kernel, stride, input_dim, out_dim))
|
| 164 |
+
input_dim = out_dim
|
| 165 |
+
self.layer_names.append(name)
|
| 166 |
+
self.classifier = nn.Linear(input_dim, 521, bias=True) # unused for emb
|
| 167 |
+
|
| 168 |
+
def embeddings(self, x):
|
| 169 |
+
"""x: [N, 1, 96, 64] mel patches -> [N, 1024] pooled embeddings."""
|
| 170 |
+
for name in self.layer_names:
|
| 171 |
+
x = getattr(self, name)(x)
|
| 172 |
+
x = F.adaptive_avg_pool2d(x, 1).reshape(x.shape[0], -1)
|
| 173 |
+
return x
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ----------------------------------------------------------------------------
|
| 177 |
+
# Log-mel frontend (core torch; reproduces w-hc VGGishLogMelSpectrogram config)
|
| 178 |
+
# ----------------------------------------------------------------------------
|
| 179 |
+
def _htk_mel_fb():
|
| 180 |
+
"""HTK triangular mel filterbank [n_freqs, n_mels], torchaudio norm=None."""
|
| 181 |
+
n_freqs = N_FFT // 2 + 1 # 257
|
| 182 |
+
all_freqs = torch.linspace(0, SR // 2, n_freqs)
|
| 183 |
+
m_min = 2595.0 * math.log10(1.0 + MEL_MIN_HZ / 700.0)
|
| 184 |
+
m_max = 2595.0 * math.log10(1.0 + MEL_MAX_HZ / 700.0)
|
| 185 |
+
m_pts = torch.linspace(m_min, m_max, N_MELS + 2)
|
| 186 |
+
f_pts = 700.0 * (10.0 ** (m_pts / 2595.0) - 1.0)
|
| 187 |
+
f_diff = f_pts[1:] - f_pts[:-1] # [n_mels+1]
|
| 188 |
+
slopes = f_pts.unsqueeze(0) - all_freqs.unsqueeze(1) # [n_freqs, n_mels+2]
|
| 189 |
+
down = -slopes[:, :-2] / f_diff[:-1]
|
| 190 |
+
up = slopes[:, 2:] / f_diff[1:]
|
| 191 |
+
return torch.clamp(torch.minimum(down, up), min=0.0) # [n_freqs, n_mels]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
_MEL_FB = _htk_mel_fb()
|
| 195 |
+
_WINDOW = torch.hann_window(WIN_SAMPLES, periodic=True)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
_MIN_SAMPLES = PATCH_FRAMES * HOP_SAMPLES # 15360 = 0.96s -> guarantees >=1 patch
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def waveform_to_patches(wav16k):
|
| 202 |
+
"""wav16k: 1-D torch float32 @16k -> mel patches [n_patches, 1, 96, 64].
|
| 203 |
+
|
| 204 |
+
Clips shorter than one 0.96s window are zero-padded up to it (paper-consistent
|
| 205 |
+
zero-padding) so every clip yields at least one frame instead of being dropped.
|
| 206 |
+
"""
|
| 207 |
+
if wav16k.shape[0] < _MIN_SAMPLES:
|
| 208 |
+
wav16k = torch.nn.functional.pad(wav16k, (0, _MIN_SAMPLES - wav16k.shape[0]))
|
| 209 |
+
stft = torch.stft(wav16k, n_fft=N_FFT, hop_length=HOP_SAMPLES,
|
| 210 |
+
win_length=WIN_SAMPLES, window=_WINDOW, center=True,
|
| 211 |
+
pad_mode="reflect", return_complex=True, normalized=False)
|
| 212 |
+
mag = stft.abs() # [n_freqs, T] (power 2 -> sqrt = magnitude)
|
| 213 |
+
mel = torch.matmul(mag.transpose(0, 1), _MEL_FB) # [T, n_mels]
|
| 214 |
+
logmel = torch.log(mel + LOG_OFFSET) # [T, 64]
|
| 215 |
+
T = logmel.shape[0]
|
| 216 |
+
n_patches = (T - PATCH_FRAMES) // PATCH_HOP_FRAMES + 1
|
| 217 |
+
if n_patches < 1:
|
| 218 |
+
return torch.empty(0, 1, PATCH_FRAMES, N_MELS)
|
| 219 |
+
idx = torch.arange(PATCH_FRAMES).unsqueeze(0) + \
|
| 220 |
+
(torch.arange(n_patches) * PATCH_HOP_FRAMES).unsqueeze(1) # [n_patches, 96]
|
| 221 |
+
patches = logmel[idx] # [n_patches, 96, 64]
|
| 222 |
+
return patches.unsqueeze(1) # [n_patches, 1, 96, 64]
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def patch_energy_dbfs(wav16k, n_patches):
|
| 226 |
+
"""Per-patch dBFS of the raw 16k waveform slice aligned to each mel patch."""
|
| 227 |
+
win = int(round(PATCH_WINDOW_S * SR)) # 15360
|
| 228 |
+
hop = int(round(PATCH_HOP_S * SR)) # 7680
|
| 229 |
+
x = wav16k.detach().cpu().numpy()
|
| 230 |
+
out = np.empty(n_patches, dtype=np.float32)
|
| 231 |
+
for i in range(n_patches):
|
| 232 |
+
seg = x[i * hop: i * hop + win]
|
| 233 |
+
rms = float(np.sqrt(np.mean(seg.astype(np.float64) ** 2))) if seg.size else 0.0
|
| 234 |
+
out[i] = 20.0 * math.log10(rms + 1e-10) # dBFS, full-scale ref = 1.0
|
| 235 |
+
return out
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ----------------------------------------------------------------------------
|
| 239 |
+
# Model load + audio load
|
| 240 |
+
# ----------------------------------------------------------------------------
|
| 241 |
+
def load_model(weights=_WEIGHTS):
|
| 242 |
+
model = YAMNet()
|
| 243 |
+
sd = torch.load(weights, map_location="cpu", weights_only=True)
|
| 244 |
+
model.load_state_dict(sd)
|
| 245 |
+
model.eval()
|
| 246 |
+
return model
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def load_audio_16k(path):
|
| 250 |
+
"""Load any wav -> mono float32 @16k (scipy resample_poly for telephony 8k)."""
|
| 251 |
+
audio, sr = sf.read(path, dtype="float32", always_2d=False)
|
| 252 |
+
if audio.ndim > 1:
|
| 253 |
+
audio = audio.mean(axis=1)
|
| 254 |
+
if sr != SR:
|
| 255 |
+
g = math.gcd(int(sr), SR)
|
| 256 |
+
audio = resample_poly(audio, SR // g, int(sr) // g).astype(np.float32)
|
| 257 |
+
return torch.from_numpy(np.ascontiguousarray(audio, dtype=np.float32))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
@torch.no_grad()
|
| 261 |
+
def extract_one(model, path):
|
| 262 |
+
wav = load_audio_16k(path)
|
| 263 |
+
patches = waveform_to_patches(wav)
|
| 264 |
+
n = patches.shape[0]
|
| 265 |
+
if n == 0:
|
| 266 |
+
return np.zeros((0, EMB_DIM), np.float32), np.zeros((0,), np.float32)
|
| 267 |
+
emb = model.embeddings(patches).numpy().astype(np.float32) # [n,1024]
|
| 268 |
+
energy = patch_energy_dbfs(wav, n)
|
| 269 |
+
return emb, energy
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ----------------------------------------------------------------------------
|
| 273 |
+
# CLI
|
| 274 |
+
# ----------------------------------------------------------------------------
|
| 275 |
+
def _safe_stem(path, idx):
|
| 276 |
+
stem = os.path.splitext(os.path.basename(path))[0]
|
| 277 |
+
return f"{idx:06d}_{stem}.npz"
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _cache_name(path):
|
| 281 |
+
"""Stable per-clip cache filename = sha1(abspath). Lets the cache survive
|
| 282 |
+
manifest changes (row reorder / add / drop) so a later index rebuild can
|
| 283 |
+
reuse .npz by path, and --skip-existing can no-op unchanged clips."""
|
| 284 |
+
return hashlib.sha1(os.path.abspath(path).encode()).hexdigest()[:16] + ".npz"
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def run_manifest(model, manifest, out_dir, name_by_hash=False, skip_existing=False):
|
| 288 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 289 |
+
rows = []
|
| 290 |
+
with open(manifest, newline="") as f:
|
| 291 |
+
reader = csv.DictReader(f)
|
| 292 |
+
if "path" not in reader.fieldnames:
|
| 293 |
+
sys.exit(f"manifest {manifest} has no 'path' column: {reader.fieldnames}")
|
| 294 |
+
entries = list(reader)
|
| 295 |
+
t0 = time.perf_counter()
|
| 296 |
+
n_reused = n_new = 0
|
| 297 |
+
for i, row in enumerate(entries):
|
| 298 |
+
path = row["path"]
|
| 299 |
+
out_name = _cache_name(path) if name_by_hash else _safe_stem(path, i)
|
| 300 |
+
out_path = os.path.join(out_dir, out_name)
|
| 301 |
+
# reuse cache only if it exists AND the source file is not newer than it
|
| 302 |
+
# (a trimmed/replaced clip at the same path has a newer mtime -> re-extract)
|
| 303 |
+
reuse = (skip_existing and os.path.exists(out_path) and os.path.exists(path)
|
| 304 |
+
and os.path.getmtime(path) <= os.path.getmtime(out_path))
|
| 305 |
+
if reuse:
|
| 306 |
+
try:
|
| 307 |
+
n_frames = int(np.load(out_path)["n_frames"])
|
| 308 |
+
except Exception:
|
| 309 |
+
os.remove(out_path)
|
| 310 |
+
else:
|
| 311 |
+
n_reused += 1
|
| 312 |
+
rows.append({"npz": out_name, "path": path,
|
| 313 |
+
"label": row.get("label", ""),
|
| 314 |
+
"label_binary": row.get("label_binary", ""),
|
| 315 |
+
"source": row.get("source", ""), "n_frames": n_frames})
|
| 316 |
+
if (i + 1) % 500 == 0:
|
| 317 |
+
print(f" {i + 1}/{len(entries)} (reused {n_reused}, new {n_new})...")
|
| 318 |
+
continue
|
| 319 |
+
try:
|
| 320 |
+
emb, energy = extract_one(model, path)
|
| 321 |
+
except Exception as e:
|
| 322 |
+
print(f" [WARN] row {i} {path}: {type(e).__name__}: {e}")
|
| 323 |
+
continue
|
| 324 |
+
np.savez(out_path, embeddings=emb, energy=energy, sr=SR,
|
| 325 |
+
n_frames=emb.shape[0])
|
| 326 |
+
n_new += 1
|
| 327 |
+
rows.append({"npz": out_name, "path": path,
|
| 328 |
+
"label": row.get("label", ""),
|
| 329 |
+
"label_binary": row.get("label_binary", ""),
|
| 330 |
+
"source": row.get("source", ""),
|
| 331 |
+
"n_frames": emb.shape[0]})
|
| 332 |
+
if (i + 1) % 500 == 0:
|
| 333 |
+
print(f" {i + 1}/{len(entries)} (reused {n_reused}, new {n_new})...")
|
| 334 |
+
print(f" extracted new={n_new} reused={n_reused}")
|
| 335 |
+
idx_csv = os.path.join(out_dir, "embeddings_index.csv")
|
| 336 |
+
with open(idx_csv, "w", newline="") as f:
|
| 337 |
+
w = csv.DictWriter(
|
| 338 |
+
f, fieldnames=["npz", "path", "label", "label_binary", "source", "n_frames"])
|
| 339 |
+
w.writeheader()
|
| 340 |
+
w.writerows(rows)
|
| 341 |
+
dt = time.perf_counter() - t0
|
| 342 |
+
print(f"Done: {len(rows)}/{len(entries)} clips in {dt:.1f}s -> {out_dir}")
|
| 343 |
+
print(f"Index: {idx_csv}")
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def run_single(model, wav, out_dir):
|
| 347 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 348 |
+
emb, energy = extract_one(model, wav)
|
| 349 |
+
out_path = os.path.join(out_dir, _safe_stem(wav, 0))
|
| 350 |
+
np.savez(out_path, embeddings=emb, energy=energy, sr=SR, n_frames=emb.shape[0])
|
| 351 |
+
print(f"{wav}: embeddings {emb.shape}, energy {energy.shape} -> {out_path}")
|
| 352 |
+
print(f" energy dBFS: {np.array2string(energy, precision=1, max_line_width=200)}")
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def selftest():
|
| 356 |
+
print("=== YAMNet extractor self-test ===")
|
| 357 |
+
model = load_model()
|
| 358 |
+
# (a) synthetic 8kHz input: 4s 440Hz tone, then 4s silence
|
| 359 |
+
sr_in = 8000
|
| 360 |
+
t = np.arange(4 * sr_in) / sr_in
|
| 361 |
+
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
| 362 |
+
silence = np.zeros(4 * sr_in, dtype=np.float32)
|
| 363 |
+
for name, sig in [("tone_4s@8k", tone), ("silence_4s@8k", silence)]:
|
| 364 |
+
wav = load_audio_16k_from_array(sig, sr_in)
|
| 365 |
+
patches = waveform_to_patches(wav)
|
| 366 |
+
with torch.no_grad():
|
| 367 |
+
emb = model.embeddings(patches).numpy()
|
| 368 |
+
energy = patch_energy_dbfs(wav, patches.shape[0])
|
| 369 |
+
finite = np.isfinite(emb).all()
|
| 370 |
+
const = bool(np.allclose(emb.std(axis=0), 0, atol=1e-4)) if emb.shape[0] > 1 else None
|
| 371 |
+
print(f"[{name}] patches={patches.shape[0]} emb={emb.shape} "
|
| 372 |
+
f"finite={finite} nan={np.isnan(emb).any()} "
|
| 373 |
+
f"emb_mean={emb.mean():.4f} emb_std={emb.std():.4f} "
|
| 374 |
+
f"all_frames_constant={const}")
|
| 375 |
+
print(f" energy dBFS = {np.array2string(energy, precision=1)}")
|
| 376 |
+
# combined tone-vs-silence separation check
|
| 377 |
+
tone_wav = load_audio_16k_from_array(tone, sr_in)
|
| 378 |
+
sil_wav = load_audio_16k_from_array(silence, sr_in)
|
| 379 |
+
with torch.no_grad():
|
| 380 |
+
e_tone = model.embeddings(waveform_to_patches(tone_wav)).numpy()
|
| 381 |
+
e_sil = model.embeddings(waveform_to_patches(sil_wav)).numpy()
|
| 382 |
+
diff = np.linalg.norm(e_tone.mean(0) - e_sil.mean(0))
|
| 383 |
+
en_tone = patch_energy_dbfs(tone_wav, waveform_to_patches(tone_wav).shape[0])
|
| 384 |
+
en_sil = patch_energy_dbfs(sil_wav, waveform_to_patches(sil_wav).shape[0])
|
| 385 |
+
print(f"[separation] ||emb_tone - emb_silence|| = {diff:.3f} (expect >> 0)")
|
| 386 |
+
print(f"[energy-gate] tone median dBFS={np.median(en_tone):.1f} vs "
|
| 387 |
+
f"silence median dBFS={np.median(en_sil):.1f} "
|
| 388 |
+
f"(gap={np.median(en_tone) - np.median(en_sil):.1f} dB)")
|
| 389 |
+
# (c) benchmark: 4s clip @8k input
|
| 390 |
+
bench_sig = tone
|
| 391 |
+
for th in [1, 8]:
|
| 392 |
+
torch.set_num_threads(th)
|
| 393 |
+
w = load_audio_16k_from_array(bench_sig, sr_in)
|
| 394 |
+
with torch.no_grad():
|
| 395 |
+
model.embeddings(waveform_to_patches(w)) # warmup
|
| 396 |
+
ts = []
|
| 397 |
+
for _ in range(10):
|
| 398 |
+
t0 = time.perf_counter()
|
| 399 |
+
p = waveform_to_patches(w)
|
| 400 |
+
model.embeddings(p)
|
| 401 |
+
patch_energy_dbfs(w, p.shape[0])
|
| 402 |
+
ts.append((time.perf_counter() - t0) * 1000)
|
| 403 |
+
ts.sort()
|
| 404 |
+
p50 = ts[len(ts) // 2]
|
| 405 |
+
rtf = 4000.0 / p50
|
| 406 |
+
print(f"[bench] threads={th}: {p50:.1f} ms / 4s clip "
|
| 407 |
+
f"(audio-sec per wall-sec = {rtf:.1f}x)")
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def load_audio_16k_from_array(sig, sr_in):
|
| 411 |
+
if sr_in != SR:
|
| 412 |
+
g = math.gcd(int(sr_in), SR)
|
| 413 |
+
sig = resample_poly(sig, SR // g, int(sr_in) // g).astype(np.float32)
|
| 414 |
+
return torch.from_numpy(np.ascontiguousarray(sig, dtype=np.float32))
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def main():
|
| 418 |
+
ap = argparse.ArgumentParser(description="YAMNet 1024-d embedding extractor")
|
| 419 |
+
ap.add_argument("--wav", help="single audio file")
|
| 420 |
+
ap.add_argument("--manifest", help="CSV with a 'path' column (+ optional label)")
|
| 421 |
+
ap.add_argument("--out-dir", default="embeddings", help="output dir for .npz")
|
| 422 |
+
ap.add_argument("--weights", default=_WEIGHTS)
|
| 423 |
+
ap.add_argument("--threads", type=int, default=0,
|
| 424 |
+
help="torch CPU threads (0 = leave default)")
|
| 425 |
+
ap.add_argument("--name-by-hash", action="store_true",
|
| 426 |
+
help="name .npz by sha1(abspath) (stable cache across manifest edits)")
|
| 427 |
+
ap.add_argument("--skip-existing", action="store_true",
|
| 428 |
+
help="reuse an existing .npz instead of re-extracting")
|
| 429 |
+
ap.add_argument("--selftest", action="store_true")
|
| 430 |
+
args = ap.parse_args()
|
| 431 |
+
|
| 432 |
+
if args.threads > 0:
|
| 433 |
+
torch.set_num_threads(args.threads)
|
| 434 |
+
|
| 435 |
+
if args.selftest:
|
| 436 |
+
selftest()
|
| 437 |
+
return
|
| 438 |
+
model = load_model(args.weights)
|
| 439 |
+
if args.manifest:
|
| 440 |
+
run_manifest(model, args.manifest, args.out_dir,
|
| 441 |
+
name_by_hash=args.name_by_hash, skip_existing=args.skip_existing)
|
| 442 |
+
elif args.wav:
|
| 443 |
+
run_single(model, args.wav, args.out_dir)
|
| 444 |
+
else:
|
| 445 |
+
sys.exit("provide --wav, --manifest, or --selftest")
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
if __name__ == "__main__":
|
| 449 |
+
main()
|
code/predict_example.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Standalone inference for the Mission-3 AMD model (track3v4_a).
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python3 predict_example.py <audio.wav> [threshold]
|
| 6 |
+
|
| 7 |
+
Deps: pip install torch numpy soundfile scipy
|
| 8 |
+
Any wav works (8k mu-law telephony audio is the trained domain; anything else
|
| 9 |
+
is resampled to 16k mono internally). Default threshold 0.5; the FTC-compliance
|
| 10 |
+
calibration starting point is 0.876 — see docs/REPORT_M3.md section 7 before
|
| 11 |
+
trusting any fixed threshold.
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 19 |
+
sys.path.insert(0, HERE)
|
| 20 |
+
import extract_embeddings as xe # noqa: E402
|
| 21 |
+
from sweep_head import build_config_head # noqa: E402
|
| 22 |
+
|
| 23 |
+
MODEL_DIR = os.path.join(HERE, "..")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def load():
|
| 27 |
+
yamnet = xe.load_model(os.path.join(MODEL_DIR, "v5b_full.yamnet.pth"))
|
| 28 |
+
ck = torch.load(os.path.join(MODEL_DIR, "v5b_full.head.pt"),
|
| 29 |
+
map_location="cpu", weights_only=False)
|
| 30 |
+
head = build_config_head(ck["config"])
|
| 31 |
+
head.load_state_dict(ck["state_dict"])
|
| 32 |
+
head.eval()
|
| 33 |
+
return yamnet, head
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def predict(yamnet, head, wav_path):
|
| 37 |
+
"""Returns p_machine in [0,1]."""
|
| 38 |
+
wav = xe.load_audio_16k(wav_path)
|
| 39 |
+
patches = xe.waveform_to_patches(wav)
|
| 40 |
+
if patches.shape[0] == 0:
|
| 41 |
+
return 0.0 # no audio frames -> fail-open as human
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
emb = yamnet.embeddings(patches) # [n, 1024]
|
| 44 |
+
x = emb.unsqueeze(0) # [1, n, 1024]
|
| 45 |
+
lengths = torch.tensor([emb.shape[0]], dtype=torch.long)
|
| 46 |
+
return float(torch.sigmoid(head(x, lengths)).item())
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if __name__ == "__main__":
|
| 50 |
+
if len(sys.argv) < 2:
|
| 51 |
+
sys.exit(__doc__)
|
| 52 |
+
torch.set_num_threads(1)
|
| 53 |
+
t = float(sys.argv[2]) if len(sys.argv) > 2 else 0.5
|
| 54 |
+
yamnet, head = load()
|
| 55 |
+
p = predict(yamnet, head, sys.argv[1])
|
| 56 |
+
print(f"p_machine={p:.4f} -> {'MACHINE' if p >= t else 'HUMAN'} (threshold {t})")
|
code/sweep_head.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configurable GRU classifier head for YAMNet embeddings (Mission-2 sweep).
|
| 2 |
+
|
| 3 |
+
Superset of gru_head.GRUHead: keeps the exact same math when built with the
|
| 4 |
+
canonical config (proj=32, hidden=48, layers=1, unidirectional, pooling='last',
|
| 5 |
+
dropout=0) but exposes every knob the round-1 grid needs:
|
| 6 |
+
|
| 7 |
+
proj_dim, hidden, num_layers, bidirectional, pooling {last|mean|attn}, dropout
|
| 8 |
+
|
| 9 |
+
`forward` returns a RAW LOGIT (no sigmoid), like gru_head, so training uses
|
| 10 |
+
BCEWithLogitsLoss for numerical stability. This module is NEW; it does not touch
|
| 11 |
+
gru_head.py / head_best.pt / the deployed adapter.
|
| 12 |
+
|
| 13 |
+
Pooling semantics (over the GRU output sequence, honoring per-clip lengths):
|
| 14 |
+
last : final hidden state h_n. Bidirectional -> concat last-layer fwd+bwd.
|
| 15 |
+
mean : mean of GRU outputs over valid timesteps.
|
| 16 |
+
attn : additive-attention weighted sum of GRU outputs over valid timesteps.
|
| 17 |
+
"""
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
|
| 21 |
+
|
| 22 |
+
EMB_DIM = 1024
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ConfigGRUHead(nn.Module):
|
| 26 |
+
def __init__(self, emb_dim=EMB_DIM, proj=32, hidden=48, num_layers=1,
|
| 27 |
+
bidirectional=False, pooling="last", dropout=0.0):
|
| 28 |
+
super().__init__()
|
| 29 |
+
assert pooling in ("last", "mean", "attn"), pooling
|
| 30 |
+
self.pooling = pooling
|
| 31 |
+
self.bidirectional = bool(bidirectional)
|
| 32 |
+
self.num_layers = int(num_layers)
|
| 33 |
+
self.hidden = int(hidden)
|
| 34 |
+
|
| 35 |
+
self.proj = nn.Linear(emb_dim, proj) # Dense(1024->proj), tanh below
|
| 36 |
+
self.in_drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
|
| 37 |
+
gru_drop = dropout if (num_layers > 1 and dropout > 0) else 0.0
|
| 38 |
+
self.gru = nn.GRU(proj, hidden, num_layers=num_layers, batch_first=True,
|
| 39 |
+
bidirectional=self.bidirectional, dropout=gru_drop)
|
| 40 |
+
|
| 41 |
+
dirs = 2 if self.bidirectional else 1
|
| 42 |
+
self.feat_dim = hidden * dirs
|
| 43 |
+
if pooling == "attn":
|
| 44 |
+
self.attn_w = nn.Linear(self.feat_dim, self.feat_dim)
|
| 45 |
+
self.attn_v = nn.Linear(self.feat_dim, 1, bias=False)
|
| 46 |
+
self.out_drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
|
| 47 |
+
self.out = nn.Linear(self.feat_dim, 1) # Dense(feat->1)
|
| 48 |
+
|
| 49 |
+
# -- pooling helpers -----------------------------------------------------
|
| 50 |
+
def _last(self, h_n):
|
| 51 |
+
"""Final hidden state -> [B, feat_dim]. h_n: [layers*dirs, B, hidden]."""
|
| 52 |
+
if self.bidirectional:
|
| 53 |
+
fwd = h_n[-2] # last layer forward
|
| 54 |
+
bwd = h_n[-1] # last layer backward
|
| 55 |
+
return torch.cat([fwd, bwd], dim=-1)
|
| 56 |
+
return h_n[-1]
|
| 57 |
+
|
| 58 |
+
def _masked_seq(self, packed_out, lengths, batch_first_shape):
|
| 59 |
+
"""Unpack -> (out [B,Tmax,feat], mask [B,Tmax] bool)."""
|
| 60 |
+
out, _ = pad_packed_sequence(packed_out, batch_first=True) # [B,Tmax,feat]
|
| 61 |
+
B, Tmax = out.shape[0], out.shape[1]
|
| 62 |
+
idx = torch.arange(Tmax, device=out.device).unsqueeze(0) # [1,Tmax]
|
| 63 |
+
mask = idx < lengths.to(out.device).unsqueeze(1) # [B,Tmax]
|
| 64 |
+
return out, mask
|
| 65 |
+
|
| 66 |
+
def forward(self, x, lengths):
|
| 67 |
+
"""x: [B, T, emb_dim] padded. lengths: [B] valid frame counts. -> logit [B]."""
|
| 68 |
+
h = torch.tanh(self.proj(x))
|
| 69 |
+
h = self.in_drop(h)
|
| 70 |
+
packed = pack_padded_sequence(h, lengths.cpu(), batch_first=True,
|
| 71 |
+
enforce_sorted=False)
|
| 72 |
+
packed_out, h_n = self.gru(packed)
|
| 73 |
+
|
| 74 |
+
if self.pooling == "last":
|
| 75 |
+
feat = self._last(h_n) # [B, feat]
|
| 76 |
+
else:
|
| 77 |
+
out, mask = self._masked_seq(packed_out, lengths, x.shape)
|
| 78 |
+
m = mask.unsqueeze(-1).float() # [B,Tmax,1]
|
| 79 |
+
if self.pooling == "mean":
|
| 80 |
+
feat = (out * m).sum(1) / m.sum(1).clamp(min=1.0)
|
| 81 |
+
else: # attn
|
| 82 |
+
score = self.attn_v(torch.tanh(self.attn_w(out))).squeeze(-1) # [B,Tmax]
|
| 83 |
+
score = score.masked_fill(~mask, float("-inf"))
|
| 84 |
+
w = torch.softmax(score, dim=1).unsqueeze(-1) # [B,Tmax,1]
|
| 85 |
+
feat = (out * w).sum(1)
|
| 86 |
+
|
| 87 |
+
feat = self.out_drop(feat)
|
| 88 |
+
return self.out(feat).squeeze(-1)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_config_head(config):
|
| 92 |
+
"""Construct a ConfigGRUHead from a config dict (arch keys only)."""
|
| 93 |
+
return ConfigGRUHead(
|
| 94 |
+
emb_dim=EMB_DIM,
|
| 95 |
+
proj=int(config.get("proj_dim", 32)),
|
| 96 |
+
hidden=int(config.get("hidden", 48)),
|
| 97 |
+
num_layers=int(config.get("num_layers", 1)),
|
| 98 |
+
bidirectional=bool(config.get("bidirectional", False)),
|
| 99 |
+
pooling=config.get("pooling", "last"),
|
| 100 |
+
dropout=float(config.get("dropout", 0.0)),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def param_count(model):
|
| 105 |
+
return sum(p.numel() for p in model.parameters())
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
# sanity: canonical config must match the 44,657-param deployed head math
|
| 110 |
+
from gru_head import PARAM_COUNT
|
| 111 |
+
m = build_config_head({"proj_dim": 32, "hidden": 48, "num_layers": 1,
|
| 112 |
+
"bidirectional": False, "pooling": "last", "dropout": 0.0})
|
| 113 |
+
n = param_count(m)
|
| 114 |
+
print(f"canonical ConfigGRUHead params = {n} (expected {PARAM_COUNT}): {n == PARAM_COUNT}")
|
| 115 |
+
x = torch.randn(3, 5, EMB_DIM)
|
| 116 |
+
lens = torch.tensor([5, 3, 1])
|
| 117 |
+
for pool in ("last", "mean", "attn"):
|
| 118 |
+
for bi in (False, True):
|
| 119 |
+
for nl in (1, 2):
|
| 120 |
+
mm = build_config_head({"proj_dim": 64, "hidden": 96, "num_layers": nl,
|
| 121 |
+
"bidirectional": bi, "pooling": pool, "dropout": 0.2})
|
| 122 |
+
y = mm(x, lens)
|
| 123 |
+
assert y.shape == (3,), (pool, bi, nl, y.shape)
|
| 124 |
+
print("forward shapes OK across pooling x bidir x layers")
|
v5b_full.head.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b1eb0b111fd1919f419e2eea134964a8f3fa9b311976441d312a83373a653368
|
| 3 |
+
size 1051115
|
v5b_full.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backbone_weights": "/root/amd/tracks/yamnet/experiments/backbone/v5b_full.yamnet.pth",
|
| 3 |
+
"head_weights": "/root/amd/tracks/yamnet/experiments/backbone/v5b_full.head.pt",
|
| 4 |
+
"head_config": {
|
| 5 |
+
"proj_dim": 128,
|
| 6 |
+
"hidden": 96,
|
| 7 |
+
"num_layers": 1,
|
| 8 |
+
"bidirectional": true,
|
| 9 |
+
"pooling": "last",
|
| 10 |
+
"dropout": 0.0
|
| 11 |
+
},
|
| 12 |
+
"gate_dbfs": -300.0,
|
| 13 |
+
"decision_threshold": 0.5,
|
| 14 |
+
"unfreeze_blocks": 2,
|
| 15 |
+
"note": "load backbone via extract_embeddings.load_model(backbone_weights); build head via sweep_head.build_config_head(head_config)."
|
| 16 |
+
}
|
v5b_full.yamnet.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:da4037dc39907021b8ac44e491a763defad1b4ff67532607fab9f86563a20cde
|
| 3 |
+
size 15101511
|