初賽靠著隊友 Carry 進決賽,決賽的時候主要負責 MLOps 的部份,分成兩篇文章來分別描述一下初賽時我的方法以及決賽時我們怎麼處理多出來的難關 — 在 AWS 上進行 Incremental training。

本實驗的貢獻

沒有用額外的資料集,也沒有 Pre-trained 模型,只將主辦單位提供的資料做 Augmentation & Pseudo Labeling 的技巧, 用 ResNet18 就獲得不錯的 Baseline 成績(Top 10%)。


Code: kehanlu/Tomofun-DogSound-recognition

Introduction

狗音辨識挑戰賽是一個 Audio Classification 的任務,在初賽的時候要將音訊分為 6 類,分別為:

  • Barking
  • Howling
  • Crying
  • COSmoke:火災警報器之類的東西
  • GlassBreaking
  • Other,包含機器音、吸塵器音、貓叫等等…。決賽則是要分成 10 類。

主辦單位提供的 Training data 為每類各 200 筆的乾淨音訊,共 1200 筆, 都是 5 秒 8k/hz 取樣的 Wav 檔。 Testing Data 分為 Public-test 10K 筆及 Private-test 20K 筆,Private-test 在比賽最後兩天才公佈,作為最終的評分成績。評分方式為各類別的 AUC,最終取 25 名進入決賽。

每個隊伍每天可以上傳兩次,最後兩天才公佈的 Private-test 只能上傳 4 次,可以選兩個模型當作最後的 Submission。

Leaderboard Results

在初賽時我和我的隊友各自發展自己的模型,雖然前處理和模型都不太一樣,但兩個結果相當接近,最終是靠他的模型得到第 22 名進入決賽,而我的模型則在 Leaderboard 上差不多是第 30 名的成績(一隻腳在裡面,另一隻在外面XD)。

我的方法其實只是把音檔做 Augmentation 之後丟進 ResNet18,並沒有特別找和語音相關的模型去做,能拿到這樣的分數我覺得滿驚訝的,代表這個任務的 Baseline 已經滿高的了。

Public Test(%) Private Test(%) Rank
(301 teams)
My model 97.697 98.060 30*
My teammate’s model 97.702 98.272 22

*Rank 30是指直接從 Fianl Leaderboard 看這個分數在第幾名。

Proposed method

Code: kehanlu/Tomofun-DogSound-recognition

因為初賽附近(6/10)有點忙,所以沒有特別去做 Paper survey,一開始的嘗試是參考 daisukelab/sound-clf-pytorch 先做一個 Baseline 出來,最後做 Data Augmentation 加上把模型換成 ResNet18 而已。

Data Augmentation

資料增強(augmentation)是我覺得是這個比賽最重要的部份,能不能取得 1200 筆以外的 Training Data 成為關鍵,在初賽和決賽都可以看出用了更多資料的隊伍都能獲得更好的成績,在我自己的實驗裡面也發現不同模型結構影響其實不大,但做了不同資料增強或增加額外資料會有很大的改善。

另外,主辦單位提供的 Testing Data 可以用 Pseudo Labeling 的技巧,用訓練好的模型先標記,然後再把這 10K 筆資料重新訓練模型,會讓模型有非常大的提昇。通常這招都會讓模型進步,而這次的任務又相對簡單,從 Public Leaderboard 上可以知道模型的 AUC 已經有 0.96~0.98 的水準,Confidence 高的音檔都可以重新丟進去訓練。

使用 iver56/audiomentations

  • Speed perturbation & Time Shift
    • 變快和變慢各一倍
  • MUSAN1 noise
    • 每個音檔,隨機抽取 5 個 Noise 檔混合成五倍的資料
  • Pseudo-label:把 10000 筆測試資料用最好的模型標記之後重新訓練

總共 (1200+10000)*7 = 78400筆音檔

Speed purturbation & Time Shift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Augment
augment = Compose([
    TimeStretch(min_rate=0.8, max_rate=1, p=1),
    Shift(min_fraction=1, max_fraction=1,rollover=True, p=0.5)
])
for f in train_test_path:
    waveform, sr = torchaudio.load(DATA_PATH/f"{f}.wav")
    augmented_sample = augment(samples=waveform.numpy(), sample_rate=sr)
    torchaudio.save(DATA_PATH/f'augment/0.9/{f}_1.wav', 
                    torch.tensor(augmented_sample),
                    sample_rate=sr)

Mix Noise file

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
augment = Compose([
    Shift(min_fraction=-1, max_fraction=1,rollover=True, p=0.5)
])

for f in train_test_path:
    for i in range(5):
        wav, sr = torchaudio.load(DATA_PATH/f"{f}.wav")

        flag = True
        while flag:
            # random choose a noise file
            noise_f = random.choice(noise_files)
            noise_wav, noise_sr = torchaudio.load(noise_f)
            if noise_wav.shape[1] >= 80000:
                flag = False
            noise_wav = augment(noise_wav.numpy(), sample_rate=noise_sr)

        wav = torchaudio.transforms.Resample(orig_freq=sr,      
                                             new_freq=noise_sr)(wav)
        if wav.shape[1] != 80000:
            wav = torch.cat([wav, torch.zeros([1, 80000-wav.shape[1]])], dim=1)
        wav = augment(wav.numpy(), sample_rate=noise_sr)
        
        alpha = random.random()*0.3 + 0.1
        
        new_wav = (wav + noise_wav[:,:80000]*alpha)/(1+alpha)
        torchaudio.save(DATA_PATH/f'augment/noise/{f}_{i}.wav', 
                        torch.tensor(new_wav),
                        sample_rate=noise_sr)

Data Preprocessing

This code is based on: daisukelab/sound-clf-pytorch

主要是把讀進來的 wav 檔轉換成 log mel spectrogram,同時做一些另外的 augmentation。

  • Gaussian Noise
  • Time & Frequcy Masking
  • Normalize
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class DogDataset(torch.utils.data.Dataset):
    def __init__(self, cfg, filenames, labels, transforms=None, norm_mean_std=None):
        self.filenames = filenames
        self.labels = labels
        self.transforms = transforms
        self.norm_mean_std = norm_mean_std
        self.unit_length = cfg.unit_length


    def __len__(self):
        return len(self.filenames)

    def __getitem__(self, index):
        assert 0 <= index and index < len(self)
        f = self.filenames[index]
        try:
            waveform, sr = torchaudio.load(DATA_ROOT/f"{f}.wav")
        except:
            print(f)
        if sr != cfg.sample_rate:
            waveform = torchaudio.transforms.Resample(sr, cfg.sample_rate)(waveform)
        
        if self.transforms is not None:
            self.transforms(waveform.numpy(), cfg.sample_rate)
        
        mel_spec = to_mel_spectrogram(waveform)
        if self.transforms is not None:
            mel_spec = torchaudio.transforms.TimeMasking(time_mask_param=80)(mel_spec)
            mel_spec = torchaudio.transforms.FrequencyMasking(freq_mask_param=80)(mel_spec)
        
        log_mel_spec = (mel_spec + torch.finfo(torch.float).eps).log()
        
        # normalize - instance based
        if self.norm_mean_std is not None:
            log_mel_spec = (log_mel_spec - self.norm_mean_std[0]) / self.norm_mean_std[1]

        # Padding if sample is shorter than expected - both head & tail are filled with 0s
        pad_size = self.unit_length - sample_length(log_mel_spec)
        if pad_size > 0:
            offset = pad_size // 2
            log_mel_spec = np.pad(log_mel_spec, ((0, 0), (0, 0), (offset, pad_size - offset)), 'constant')

        # Random crop
        crop_size = sample_length(log_mel_spec) - self.unit_length
        if crop_size > 0:
            start = np.random.randint(0, crop_size)
            log_mel_spec = log_mel_spec[..., start:start + self.unit_length]

        # Apply augmentations
        log_mel_spec = torch.Tensor(log_mel_spec)
        return log_mel_spec, self.labels[index]

Model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ResNet(nn.Module):
    def __init__(self):
        super(ResNet, self).__init__()
        model = torch.hub.load('pytorch/vision:v0.9.0',
                               'resnet18',
                               pretrained=False)
        model.fc = nn.Linear(512, 512)
        model.conv1 = nn.Conv2d(1, 64,
                                kernel_size=(7, 7), 
                                stride=(2, 2), 
                                padding=(3, 3), 
                                bias=False)
        self.encoder = model
        self.cf = nn.Sequential(
            nn.Dropout(0.1),
            nn.Linear(512, 6)
        )
        
    def forward(self, x):
        x = self.encoder(x)
        output = self.cf(x)
        return output

Some hparams

1
2
3
learning_rate=5e-4
epochs=40
batch_size=512

Results

Public-test(%) Private-test(%)
shallow CNN*-1 95.1
shallow CNN*-2 97.5
ResNet18-1 96.0
ResNet18-2 97.69 98.06

*Byol-a 模型,使用三層 CNN 去抽取 feature,但本次實驗沒有使用到 Pre-trained weight。

  • shallow CNN-1
    • Data: 1200, augmented
  • shallow CNN-2
    • Data: 1200+10K, augmented
  • ResNet18-1
    • Data: 1200+10K, augmented
    • Epoch: 100
  • ResNet18-2
    • Data: 1200+10K, augmented
    • Epoch: 40

Discussion

失敗的嘗試

  • 我自己嘗試過把同一個類別的聲音混合,當作一個新的資料,但效果沒有很顯著
  • 將 Encoder output 的向量除了做 6 類的分類外,另外用一個二元分類器「是不是狗」的任務(Barking, Howling, Crying是狗,另三類不是狗),效果也沒有很顯著

雖然沒有太多時間做更深入的實驗,不過我觀察到幾個可以改進的方向:

站在巨人肩膀上

  • 蒐集更多 Data,把類似的任務拿來使用,例如: AudioSet2、ESC-503 之類的
  • 除了使用更大的模型外,如果能把在 AudioSet 的 pre-trained weight 拿來接著 Fine-tune 會更理想。
  • 我認為這應該算是打這種比賽的第一步吧,先 Survey SOTA 模型,然後再針對目前的任務去做修正。

  1. MUSAN: A Music, Speech, and Noise Corpus ↩︎

  2. AudioSet - A sound vocabulary and dataset ↩︎

  3. ESC: Dataset for Environmental Sound Classification ↩︎