如何才能信任你的深度學(xué)習(xí)代碼?
深度學(xué)習(xí)是一門(mén)很難評(píng)估代碼正確性的學(xué)科。隨機(jī)初始化、龐大的數(shù)據(jù)集和權(quán)重的有限可解釋性意味著,要找到模型為什么不能訓(xùn)練的確切問(wèn)題,大多數(shù)時(shí)候都需要反復(fù)試驗(yàn)。在傳統(tǒng)的軟件開(kāi)發(fā)中,自動(dòng)化單元測(cè)試是確定代碼是否完成預(yù)期任務(wù)的面包和黃油。它幫助開(kāi)發(fā)人員信任他們的代碼,并在引入更改時(shí)更加自信。一個(gè)破壞性的更改將會(huì)被單元測(cè)試檢測(cè)到。
從GitHub上許多研究庫(kù)的情況來(lái)看,深度學(xué)習(xí)的實(shí)踐者們還不喜歡這種方法。從業(yè)者不知道他們的代碼是否正常工作,他們能接受嗎?通常,由于上述三個(gè)原因,學(xué)習(xí)系統(tǒng)的每個(gè)組件的預(yù)期行為并不容易定義。然而,我相信實(shí)踐者和研究人員應(yīng)該重新考慮他們對(duì)單元測(cè)試的厭惡,因?yàn)樗梢詭椭芯窟^(guò)程更加順利。你只需要學(xué)習(xí)如何信任你的代碼。
顯然,我不是第一個(gè),也不是最后一個(gè)談?wù)撚糜谏疃葘W(xué)習(xí)的單元測(cè)試的人。如果你對(duì)這個(gè)話題感興趣,你可以看看這里:
- A Recipe for Training Neural Networks by Andrej Karpathy
- How to Unit Test Deep Learning by Sergios Karagiannakos
這篇文章的靈感來(lái)自于上面提到的,可能還有很多我現(xiàn)在想不起來(lái)的。為了在討論中增加一些內(nèi)容,我們將重點(diǎn)關(guān)注如何編寫(xiě)可重用的單元測(cè)試,這樣就可以“不去自己重復(fù)自己“。
我們的例子將測(cè)試用PyTorch編寫(xiě)的系統(tǒng)的組件,該系統(tǒng)在MNIST 上訓(xùn)練可變自動(dòng)編碼器(VAE)。你可以在github.com/tilman151/unittest_dl上找到本文中的所有代碼。
什么是單元測(cè)試?
如果您熟悉單元測(cè)試,可以跳過(guò)此部分。對(duì)于其他人,我們將看到Python中的單元測(cè)試是什么樣子的。為了簡(jiǎn)單起見(jiàn),我們將使用內(nèi)置的包unittest,而不是其他花哨的包。
一般來(lái)說(shuō),單元測(cè)試的目的是檢查代碼是否正確地運(yùn)行。通常(我也為此感到內(nèi)疚很長(zhǎng)一段時(shí)間),你會(huì)看到這樣的東西在一個(gè)文件的結(jié)尾:
- if __name__ == 'main':
- net = Network()
- x = torch.randn(4, 1, 32, 32)
- y = net(x)
- print(y.shape)
如果直接執(zhí)行該文件,則代碼片段將構(gòu)建一個(gè)網(wǎng)絡(luò),執(zhí)行前向傳遞并打印輸出的形狀。這樣,我們就可以看到向前傳播是否會(huì)拋出錯(cuò)誤,以及輸出的形狀是否可信。如果將代碼分發(fā)到不同的文件中,則必須手動(dòng)運(yùn)行每個(gè)文件,并檢查打印到控制臺(tái)的內(nèi)容。更糟糕的是,這個(gè)代碼片段有時(shí)會(huì)在運(yùn)行后被刪除,當(dāng)有變化時(shí)被重寫(xiě)。
原則上,這已經(jīng)是一個(gè)基本的單元測(cè)試。我們所要做的就是將它形式化一點(diǎn),使它能夠輕松地自動(dòng)運(yùn)行。它看起來(lái)是這樣的:
- import unittest
- class MyFirstTest(unittest.TestCase):
- def test_shape(self):
- net = Network()
- x = torch.randn(4, 1, 32, 32)
- y = net(x)
- self.assertEqual(torch.Size((10,)), y.shape)
unittest包的主要組件是類(lèi)TestCase。單個(gè)單元測(cè)試是TestCase子類(lèi)的成員函數(shù)。在我們的例子中,包將自動(dòng)檢測(cè)類(lèi)MyFirstTest并運(yùn)行函數(shù)'test_shape。如果滿(mǎn)足assertEqual調(diào)用的條件,則測(cè)試成功。否則,或者如果它崩潰,測(cè)試將失敗。
我需要測(cè)試些什么?
現(xiàn)在我們已經(jīng)了解了單元測(cè)試是如何工作的,下一個(gè)問(wèn)題是我們應(yīng)該測(cè)試什么。下面你可以看到我們的例子的代碼結(jié)構(gòu):
- |- src
- |- dataset.py
- |- model.py
- |- trainer.py
- |- run.py
我們將測(cè)試每個(gè)文件中的功能除了run.py,因?yàn)樗皇俏覀兂绦虻娜肟邳c(diǎn)。
Dataset
我們?cè)诶又惺褂玫臄?shù)據(jù)集是torchvisionMNIST類(lèi)。因此,我們可以假設(shè)像加載圖像和訓(xùn)練/測(cè)試分割這樣的基本功能可以正常工作。然而,MNIST類(lèi)為配置提供了充足的機(jī)會(huì),因此我們應(yīng)該測(cè)試是否正確配置了所有內(nèi)容。dataset.py文件包含一個(gè)名為MyMNIST的類(lèi),它有兩個(gè)成員變量。成員train_data有torchvisionMNIST類(lèi)的一個(gè)實(shí)例,該實(shí)例被配置為加載數(shù)據(jù)的訓(xùn)練部分,而test_data 中的實(shí)例加載測(cè)試部分。兩種方法都將每幅圖像每邊填充2個(gè)像素,并將像素值歸一化在[- 1,1]之間。此外,train_data 對(duì)每個(gè)圖像應(yīng)用隨機(jī)旋轉(zhuǎn)來(lái)增強(qiáng)數(shù)據(jù)。
數(shù)據(jù)的形狀
為了繼續(xù)使用上面的代碼片段,我們將首先測(cè)試數(shù)據(jù)集是否輸出了我們想要的形狀。圖像的填充意味著,它們現(xiàn)在的大小應(yīng)該是32x32像素。我們的測(cè)試看起來(lái)是這樣的:
- def test_shape(self):
- dataset = MyMNIST()
- sample, _ = dataset.train_data[0]
- self.assertEqual(torch.Shape((1, 32, 32)), sample.shape)
現(xiàn)在我們可以確定我們的padding是我們想要的。這可能看起來(lái)很瑣碎,你們中的一些人可能會(huì)認(rèn)為我在測(cè)試這個(gè)方面很迂腐,但是我不知道我有多少次因?yàn)槲腋悴磺宄畛浜瘮?shù)是如何工作的而導(dǎo)致了形狀錯(cuò)誤。像這樣的簡(jiǎn)單測(cè)試編寫(xiě)起來(lái)很快,并且可以為你以后省去許多麻煩。
數(shù)據(jù)的縮放
我們配置的下一件事是數(shù)據(jù)的縮放。在我們的例子中,這非常簡(jiǎn)單。我們希望確保每個(gè)圖像的像素值在[- 1,1]之間。與之前的測(cè)試相反,我們將對(duì)數(shù)據(jù)集中的所有圖像進(jìn)行測(cè)試。通過(guò)這種方式,我們可以確定我們關(guān)于如何縮放數(shù)據(jù)的假設(shè)對(duì)于整個(gè)數(shù)據(jù)集是有效的。
- def test_scaling(self):
- dataset = MyMNIST()
- for sample, _ in dataset.train_data:
- self.assertGreaterEqual(1, sample.max())
- self.assertLessEqual(-1, sample.min())
- self.assertTrue(torch.any(sample < 0))
- self.assertTrue(torch.any(sample > 0))
如你所見(jiàn),我們不僅要測(cè)試每個(gè)圖像的最大值和最小值是否在范圍內(nèi)。我們還通過(guò)斷言測(cè)試是否存在大于零和小于零的值,我們將值縮放到[0,1]。這個(gè)測(cè)試之所以有效,是因?yàn)槲覀兛梢约僭O(shè)MNIST中的每個(gè)圖像都覆蓋了整個(gè)范圍的值。對(duì)于更復(fù)雜的數(shù)據(jù),比如自然圖像,我們需要一個(gè)更復(fù)雜的測(cè)試條件。如果你的縮放基于數(shù)據(jù)的統(tǒng)計(jì)信息,那么測(cè)試一下是否只使用訓(xùn)練部分來(lái)計(jì)算這些統(tǒng)計(jì)信息也是一個(gè)好主意。
數(shù)據(jù)增強(qiáng)
增加訓(xùn)練數(shù)據(jù)可以極大地幫助提高模型的性能,特別是在數(shù)據(jù)量有限的情況下。另一方面,我們不會(huì)增加我們的測(cè)試數(shù)據(jù),因?yàn)槲覀兿胍3治覀兊哪P偷脑u(píng)估確定性。這意味著,我們應(yīng)該測(cè)試我們的訓(xùn)練數(shù)據(jù)是否增加了,而我們的測(cè)試數(shù)據(jù)沒(méi)有。敏銳的讀者會(huì)在這一點(diǎn)上注意到一些重要的東西。到目前為止,我們的測(cè)試只涵蓋了訓(xùn)練數(shù)據(jù)。這是需要強(qiáng)調(diào)的一點(diǎn):
始終在訓(xùn)練和測(cè)試數(shù)據(jù)上運(yùn)行測(cè)試
僅僅因?yàn)槟愕拇a在數(shù)據(jù)的一個(gè)部分上工作,并不能保證在另一個(gè)部分上不存在未檢測(cè)到的bug。對(duì)于數(shù)據(jù)增強(qiáng),我們甚至希望為每個(gè)部分?jǐn)嘌源a的不同行為。
對(duì)于我們的增強(qiáng)問(wèn)題,一個(gè)簡(jiǎn)單的測(cè)試現(xiàn)在是加載一個(gè)樣本兩次,然后檢查兩個(gè)版本是否相等。簡(jiǎn)單的解決方案是為我們的每一個(gè)部分寫(xiě)一個(gè)測(cè)試函數(shù):
- def test_augmentation_active_train_data(self):
- dataset = MyMNIST()
- are_same = []
- for i in range(len(dataset.train_data)):
- sample_1, _ = dataset.train_data[i]
- sample_2, _ = dataset.train_data[i]
- are_same.append(0 == torch.sum(sample_1 - sample_2))
- self.assertTrue(not all(are_same))
- def test_augmentation_inactive_test_data(self):
- dataset = MyMNIST()
- are_same = []
- for i in range(len(dataset.test_data)):
- sample_1, _ = dataset.test_data[i]
- sample_2, _ = dataset.test_data[i]
- are_same.append(0 == torch.sum(sample_1 - sample_2))
- self.assertTrue(all(are_same))
這些函數(shù)測(cè)試我們想要測(cè)試的內(nèi)容,但是,正如你所看到的,它們幾乎就是重復(fù)的。這有兩個(gè)主要的缺點(diǎn)。首先,如果在測(cè)試中需要更改某些內(nèi)容,我們必須記住在兩個(gè)函數(shù)中都要更改。其次,如果我們想添加另一個(gè)部分,例如一個(gè)驗(yàn)證部分,我們將不得不第三次復(fù)制測(cè)試。要解決這個(gè)問(wèn)題,我們應(yīng)該將測(cè)試功能提取到一個(gè)單獨(dú)的函數(shù)中,然后由真正的測(cè)試函數(shù)調(diào)用兩次。重構(gòu)后的測(cè)試看起來(lái)像這樣:
- def test_augmentation(self):
- dataset = MyMNIST()
- self._check_augmentation(dataset.train_data, active=True)
- self._check_augmentation(dataset.test_data, active=False)
- def _check_augmentation(self, data, active):
- are_same = []
- for i in range(len(data)):
- sample_1, _ = data[i]
- sample_2, _ = data[i]
- are_same.append(0 == torch.sum(sample_1 - sample_2))
- if active:
- self.assertTrue(not all(are_same))
- else:
- self.assertTrue(all(are_same))
_check_augmentation函數(shù)斷言給定的數(shù)據(jù)集是否進(jìn)行了增強(qiáng),并有效地刪除代碼中的重復(fù)。函數(shù)本身不會(huì)由unittest包自動(dòng)運(yùn)行,因?yàn)樗皇且詔est_開(kāi)頭的。因?yàn)槲覀兊臏y(cè)試函數(shù)現(xiàn)在真的很短,我們把它們合并成一個(gè)組合函數(shù)。它們測(cè)試了增強(qiáng)是如何工作的這一單一的概念,因此應(yīng)該屬于相同的測(cè)試函數(shù)。但是,通過(guò)這個(gè)組合,我們引入了另一個(gè)問(wèn)題。如果測(cè)試失敗了,現(xiàn)在很難直接看到哪一個(gè)部分失敗了。這個(gè)包只告訴我們組合函數(shù)的名稱(chēng)。進(jìn)入subTest函數(shù)。TestCase類(lèi)有一個(gè)成員函數(shù)subTest,它可以在一個(gè)測(cè)試函數(shù)中標(biāo)記不同的測(cè)試組件。這樣,包就可以準(zhǔn)確地告訴我們測(cè)試的哪一部分失敗了。最后的函數(shù)是這樣的:
- def test_augmentation(self):
- dataset = MyMNIST()
- with self.subTest(split='train'):
- self._check_augmentation(dataset.train_data, active=True)
- with self.subTest(split='test'):
- self._check_augmentation(dataset.test_data, active=False)
現(xiàn)在我們有了一個(gè)無(wú)重復(fù)、精確定位、可重用的測(cè)試功能。我們?cè)诖怂褂玫暮诵脑瓌t可以應(yīng)用到我們?cè)谇懊鎺坠?jié)中編寫(xiě)的所有其他單元測(cè)試中。你可以在附帶的存儲(chǔ)庫(kù)中看到結(jié)果測(cè)試。
數(shù)據(jù)的加載
數(shù)據(jù)集的最后一種類(lèi)型的單元測(cè)試與我們的例子并不完全相關(guān),因?yàn)槲覀兪褂玫氖莾?nèi)置數(shù)據(jù)集。無(wú)論如何我們都會(huì)把它包括進(jìn)來(lái),因?yàn)樗w了我們學(xué)習(xí)系統(tǒng)的一個(gè)重要部分。通常,你將在dataloader類(lèi)中使用數(shù)據(jù)集,該類(lèi)處理批處理并可以并行化加載。因此,測(cè)試你的數(shù)據(jù)集在單進(jìn)程和多進(jìn)程模式下是否與dataloader一起工作是一個(gè)好主意??紤]到我們所學(xué)到的增強(qiáng)測(cè)試,測(cè)試函數(shù)如下所示:
- def test_single_process_dataloader(self):
- dataset = MyMNIST()
- with self.subTest(split='train'):
- self._check_dataloader(dataset.train_data, num_workers=0)
- with self.subTest(split='test'):
- self._check_dataloader(dataset.test_data, num_workers=0)
- def test_multi_process_dataloader(self):
- dataset = MyMNIST()
- with self.subTest(split='train'):
- self._check_dataloader(dataset.train_data, num_workers=2)
- with self.subTest(split='test'):
- self._check_dataloader(dataset.test_data, num_workers=2)
- def _check_dataloader(self, data, num_workers):
- loader = DataLoader(data, batch_size=4, num_workers=num_workers)
- for _ in loader:
- pass
函數(shù)_check_dataloader不會(huì)對(duì)加載的數(shù)據(jù)進(jìn)行任何測(cè)試。我們只是想檢查加載過(guò)程是否沒(méi)有拋出錯(cuò)誤。理論上,ni 也可以檢查諸如正確的批大小或填充的序列數(shù)據(jù)的不同長(zhǎng)度。因?yàn)槲覀優(yōu)閐ataloader使用了最基本的配置,所以可以省略這些檢查。
同樣,這個(gè)測(cè)試可能看起來(lái)瑣碎而沒(méi)有必要,但是讓我給你一個(gè)例子,在這個(gè)簡(jiǎn)單的檢查中節(jié)省了我的時(shí)間。這個(gè)項(xiàng)目需要從pandas的dataframes中加載序列數(shù)據(jù),并從這些datafames上的滑動(dòng)窗口中構(gòu)造樣本。我們的數(shù)據(jù)集太大了,無(wú)法裝入內(nèi)存,所以我們必須按需加載數(shù)據(jù)模型,并從中剪切出所請(qǐng)求的序列。為了提高加載速度,我們決定用一個(gè)LRU cache來(lái)緩存一些數(shù)據(jù)文件。它在我們?cè)缙诘膯芜M(jìn)程實(shí)驗(yàn)中如預(yù)期的那樣工作,因此我們決定將它包含在代碼庫(kù)中。結(jié)果是,這個(gè)緩存不能很好地用于多進(jìn)程,但是我們的單元測(cè)試提前發(fā)現(xiàn)了這個(gè)問(wèn)題。在使用多進(jìn)程時(shí),我們停用了緩存,避免了以后出現(xiàn)令人不快的意外。
最后要注意的
有些人可能已經(jīng)在我們的單元測(cè)試中看到了另一個(gè)重復(fù)的模式。每個(gè)測(cè)試對(duì)訓(xùn)練數(shù)據(jù)運(yùn)行一次,對(duì)測(cè)試數(shù)據(jù)運(yùn)行一次,產(chǎn)生相同的四行代碼:
- with self.subTest(split='train'):
- self._check_something(dataset.train_data)
- with self.subTest(split='test'):
- self._check_dataloader(dataset.test_data)
也完全有理由消除這種重復(fù)。不幸的是,這將涉及到創(chuàng)建一個(gè)高階函數(shù),以函數(shù)_check_something作為參數(shù)。有時(shí),例如對(duì)于增強(qiáng)測(cè)試,我們還需要向_check_something函數(shù)傳遞額外的參數(shù)。最后,所需的編程構(gòu)造將引入更多的復(fù)雜性,并模糊要測(cè)試的概念。一般的規(guī)則是,為了可讀性和可重用性,讓你的測(cè)試代碼盡可能在需要的范圍內(nèi)變復(fù)雜。
Model
模型可以說(shuō)是學(xué)習(xí)系統(tǒng)的核心組件,通常需要是完全可配置的。這意味著,還有很多東西需要測(cè)試。幸運(yùn)的是,PyTorch中用于神經(jīng)網(wǎng)絡(luò)模型的API非常簡(jiǎn)潔,大多數(shù)實(shí)踐者都非常嚴(yán)格地使用它。這使得為模型編寫(xiě)可重用的單元測(cè)試相當(dāng)容易。
我們的模型是一個(gè)簡(jiǎn)單的VAE,由一個(gè)全連接的編碼器和解碼器組成。前向函數(shù)接受輸入圖像,對(duì)其進(jìn)行編碼,執(zhí)行重新參數(shù)化操作,然后將隱編碼解碼為圖像。雖然相對(duì)簡(jiǎn)單,但這種變換可以演示幾個(gè)值得進(jìn)行單元測(cè)試的方面。
模型的輸出形狀
我們?cè)诒疚拈_(kāi)頭看到的第一段代碼是幾乎每個(gè)人都要做的測(cè)試。我們也已經(jīng)知道這個(gè)測(cè)試是如何寫(xiě)成單元測(cè)試的。我們要做的唯一一件事就是添加要測(cè)試的正確形狀。對(duì)于一個(gè)自動(dòng)編碼器,就簡(jiǎn)單的判斷和輸入的形狀是否相同:
- @torch.nograd()
- def test_shape(self):
- net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- inputs = torch.randn(4, 1, 32, 32)
- outputs = net(x)
- self.assertEqual(inputs.shape, outputs.shape)
同樣,這很簡(jiǎn)單,但有助于找到一些最?lèi)廊说腷ug。例如,在將模型輸出從拉平的表示中reshape時(shí)忘記添加通道維度。
我們最后增加的測(cè)試是torch.nograd 。它告訴PyTorch這個(gè)函數(shù)不需要記錄梯度,并給我們一個(gè)小的加速。對(duì)于每個(gè)測(cè)試來(lái)說(shuō),它可能不是很多,但是你永遠(yuǎn)不知道需要編寫(xiě)多少。同樣,這是另一個(gè)可引用的單元測(cè)試智慧:
讓你的測(cè)試更快。否則,沒(méi)有人會(huì)想要運(yùn)行它們。
單元測(cè)試應(yīng)該在開(kāi)發(fā)期間非常頻繁地運(yùn)行。如果你的測(cè)試運(yùn)行時(shí)間很長(zhǎng),那么你可以跳過(guò)它們。
模型的移動(dòng)
在CPU上訓(xùn)練深度神經(jīng)網(wǎng)絡(luò)在大多數(shù)時(shí)候都非常慢。這就是為什么我們使用GPU來(lái)加速它。為此,我們所有的模型參數(shù)必須駐留在GPU上。因此,我們應(yīng)該斷言我們的模型可以在設(shè)備(CPU和多個(gè)GPU)之間正確地移動(dòng)。
我們可以用一個(gè)常見(jiàn)的錯(cuò)誤來(lái)說(shuō)明我們的例子VAE中的問(wèn)題。這里你可以看到bottleneck函數(shù),執(zhí)行重新參數(shù)化的技巧:
- def bottleneck(self, mu, log_sigma):
- noise = torch.randn(mu.shape)
- latent_code = log_sigma.exp() * noise + mu
- return latent_code
它取隱先驗(yàn)的參數(shù),從標(biāo)準(zhǔn)高斯分布中采樣一個(gè)噪聲張量,并使用參數(shù)對(duì)其進(jìn)行變換。這在CPU上運(yùn)行沒(méi)有問(wèn)題,但當(dāng)模型移動(dòng)到GPU時(shí)失敗。問(wèn)題是噪音張量是在CPU內(nèi)存中創(chuàng)建的,因?yàn)樗悄J(rèn)的,并沒(méi)有移動(dòng)到模型所在的設(shè)備上。一個(gè)簡(jiǎn)單的錯(cuò)誤和一個(gè)簡(jiǎn)單的解決方案。我們用noise = torch.randn_like(mu)替換了這行有問(wèn)題的代碼。這就產(chǎn)生了一個(gè)與張量mu相同形狀和在相同設(shè)備上的噪聲張量。
幫助我們盡早捕獲這些bug的測(cè)試:
- @torch.no_grad()
- @unittest.skipUnless(torch.cuda.is_available(), 'No GPU was detected')
- def test_device_moving(self):
- net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- net_on_gpu = net.to('cuda:0')
- net_back_on_cpu = net_on_gpu.cpu()
- inputs = torch.randn(4, 1, 32, 32)
- torch.manual_seed(42)
- outputs_cpu = net(inputs)
- torch.manual_seed(42)
- outputs_gpu = net_on_gpu(inputs.to('cuda:0'))
- torch.manual_seed(42)
- outputs_back_on_cpu = net_back_on_cpu(inputs)
- self.assertAlmostEqual(0., torch.sum(outputs_cpu - outputs_gpu.cpu()))
- self.assertAlmostEqual(0., torch.sum(outputs_cpu - outputs_back_on_cpu))
我們把網(wǎng)絡(luò)從一個(gè)CPU移動(dòng)到另一個(gè)CPU,然后再移動(dòng)回來(lái),只是為了確保正確?,F(xiàn)在我們有了網(wǎng)絡(luò)的三份拷貝(移動(dòng)網(wǎng)絡(luò)復(fù)制了它們),并使用相同的輸入張量向前傳遞。如果網(wǎng)絡(luò)被正確移動(dòng),前向傳遞應(yīng)該在不拋出錯(cuò)誤的情況下運(yùn)行,并且每次產(chǎn)生相同的輸出。
為了運(yùn)行這個(gè)測(cè)試,我們顯然需要一個(gè)GPU,但也許我們想在筆記本電腦上做一些快速測(cè)試。如果PyTorch沒(méi)有檢測(cè)到GPU,unittest.skipUnless 可以跳過(guò)測(cè)試。這樣可以避免將測(cè)試結(jié)果與失敗的測(cè)試混淆。
你還可以看到,我們?cè)诿看瓮ㄟ^(guò)之前固定了torch的隨機(jī)種子。我們必須這樣做,因?yàn)閂AEs是非確定性的,否則我們會(huì)得到不同的結(jié)果。這說(shuō)明了深度學(xué)習(xí)代碼單元測(cè)試的另一個(gè)重要概念:
在測(cè)試中控制隨機(jī)性。
如果你不能確保你的模型能到邊界情況,你如何測(cè)試你的模型的一個(gè)罕見(jiàn)邊界條件?如何確保模型的輸出是確定性的?你如何知道一個(gè)失敗的測(cè)試是由于隨機(jī)的偶然還是由于你引入的bug ?通過(guò)手動(dòng)設(shè)置深度學(xué)習(xí)框架的種子,可以消除函數(shù)中的隨機(jī)性。此外,還應(yīng)該將CuDNN設(shè)置為確定性模式。這主要影響卷積,但無(wú)論如何是一個(gè)好主意。
注意確定正在使用的所有框架的種子。Numpy和內(nèi)置的Python隨機(jī)數(shù)生成器有它們自己的種子,必須分別設(shè)置。有一個(gè)這樣的函數(shù)是很有用的:
- def make_deterministic(seed=42):
- # PyTorch
- torch.manual_seed(seed)
- if torch.cuda.is_available():
- torch.backends.cudnn.deterministic = True
- torch.backends.cudnn.benchmark = False
- # Numpy
- np.random.seed(seed)
- # Built-in Python
- random.seed(seed)
模型到采樣獨(dú)立性
在99。99%的情況下,你都想用隨機(jī)梯度下降的方式來(lái)訓(xùn)練你的模型。你給你的模型一個(gè)minibatch的樣本,并計(jì)算他們的平均損失。批量處理訓(xùn)練樣本假設(shè)你的模型可以處理每個(gè)樣本,也就是你可以獨(dú)立的把樣本喂給模型。換句話說(shuō),你的batch中的樣本在你的模型處理時(shí)不會(huì)相互影響。這個(gè)假設(shè)是很脆弱的,如果在一個(gè)錯(cuò)誤的張量維度上進(jìn)行錯(cuò)誤的reshape或aggregation,就會(huì)打破這個(gè)假設(shè)。
下面的測(cè)試通過(guò)執(zhí)行與輸入相關(guān)的前向和后向傳遞來(lái)檢查樣本的獨(dú)立性。在對(duì)這個(gè)batch做平均損失之前,我們把損失乘以零。如果我們的模型保持樣本獨(dú)立性,這將導(dǎo)致一個(gè)零梯度。唯一的事情,我們必須斷言,如果只有masked的樣本梯度是零:
- def test_batch_independence(self):
- inputs = torch.randn(4, 1, 32, 32)
- inputs.requires_grad = True
- net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- # Compute forward pass in eval mode to deactivate batch norm
- net.eval()
- outputs = net(inputs)
- net.train()
- # Mask loss for certain samples in batch
- batch_size = inputs[0].shape[0]
- mask_idx = torch.randint(0, batch_size, ())
- mask = torch.ones_like(outputs)
- mask[mask_idx] = 0
- outputs = outputs * mask
- # Compute backward pass
- loss = outputs.mean()
- loss.backward()
- # Check if gradient exists and is zero for masked samples
- for i, grad in enumerate(inputs.grad):
- if i == mask_idx:
- self.assertTrue(torch.all(grad == 0).item())
- else:
- self.assertTrue(not torch.all(grad == 0))
如果你準(zhǔn)確地閱讀了代碼片段,你會(huì)注意到我們將模型設(shè)置為evaluation模式。這是因?yàn)閎atch normalization違反了我們上面的假設(shè)。進(jìn)程均值和標(biāo)準(zhǔn)差的處理交叉污染了我們batch中的樣本,所以我們通過(guò)evaluation模式停止了對(duì)樣本的更新。我們可以這樣做,因?yàn)槲覀兊哪P驮谟?xùn)練和評(píng)估模式中表現(xiàn)相同。如果你的模型不是這樣的,你將不得不找到另一種方法來(lái)禁用它進(jìn)行測(cè)試。一個(gè)選項(xiàng)是用instance normalization臨時(shí)替換它。
上面的測(cè)試函數(shù)非常通用,可以按原樣復(fù)制。例外情況是,如果你的模型接受多個(gè)輸入。處理這個(gè)問(wèn)題的附加代碼是必要的。
模型的參數(shù)更新
下一個(gè)測(cè)試也與梯度有關(guān)。當(dāng)你的網(wǎng)絡(luò)架構(gòu)變得更加復(fù)雜時(shí),比如初始化,很容易構(gòu)建死子圖。死子圖是網(wǎng)絡(luò)中包含可學(xué)習(xí)參數(shù)的一部分,前向傳遞、后向傳遞或兩者都不使用。這就像在構(gòu)造函數(shù)中構(gòu)建一個(gè)網(wǎng)絡(luò)層,然后忘記在forward函數(shù)中應(yīng)用它一樣簡(jiǎn)單。
找到這些死子圖可以通過(guò)運(yùn)行優(yōu)化步驟并檢查梯度你的網(wǎng)絡(luò)參數(shù):
- def test_all_parameters_updated(self):
- net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- optim = torch.optim.SGD(net.parameters(), lr=0.1)
- outputs = net(torch.randn(4, 1, 32, 32))
- loss = outputs.mean()
- loss.backward()
- optim.step()
- for param_name, param in self.net.named_parameters():
- if param.requires_grad:
- with self.subTest(name=param_name):
- self.assertIsNotNone(param.grad)
- self.assertNotEqual(0., torch.sum(param.grad ** 2))
參數(shù)函數(shù)返回的模型的所有參數(shù)在優(yōu)化步驟后都應(yīng)該有一個(gè)梯度張量。此外,對(duì)于我們所使用的損失,它不應(yīng)該是零。測(cè)試假設(shè)模型中的所有參數(shù)都需要梯度。即使是那些不應(yīng)該被更新的參數(shù)也會(huì)首先檢查requires_grad標(biāo)志。如果任何參數(shù)在測(cè)試中失敗,子測(cè)試的名稱(chēng)將提示你在哪里查找。
提高重用性
現(xiàn)在我們已經(jīng)寫(xiě)出了模型的所有測(cè)試,我們可以將它們作為一個(gè)整體進(jìn)行分析。我們將注意到這些測(cè)試有兩個(gè)共同點(diǎn)。所有測(cè)試都從創(chuàng)建模型和定義示例輸入批處理開(kāi)始。與以往一樣,這種冗余級(jí)別有可能導(dǎo)致拼寫(xiě)錯(cuò)誤和不一致。此外,你不希望在更改模型的構(gòu)造函數(shù)時(shí)分別更新每個(gè)測(cè)試。
幸運(yùn)的是,unittest為我們提供了一個(gè)簡(jiǎn)單的解決方案,即setUp函數(shù)。這個(gè)函數(shù)在執(zhí)行TestCase中的每個(gè)測(cè)試函數(shù)之前被調(diào)用,通常為空。通過(guò)在setUp中將模型和輸入定義為T(mén)estCase的成員變量,我們可以在一個(gè)地方初始化測(cè)試的組件。
- class TestVAE(unittest.TestCase):
- def setUp(self):
- self.net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- self.test_input = torch.random(4, 1, 32, 32)
- ... # Test functions
現(xiàn)在我們用各自的成員變量替換出現(xiàn)的net和inputs,這樣就完成了。如果你想更進(jìn)一步,對(duì)所有測(cè)試使用相同的模型實(shí)例,您可以使用setUpClass。這個(gè)函數(shù)在構(gòu)造TestCase時(shí)被調(diào)用一次。如果構(gòu)建速度很慢,并且你不想多次進(jìn)行構(gòu)建,那么這是非常有用的。
在這一點(diǎn)上,我們有一個(gè)整潔的系統(tǒng)來(lái)測(cè)試我們的VAE模型。我們可以輕松地添加測(cè)試,并確保每次都測(cè)試模型的相同版本。但是如果你想引入一種新的卷積層,會(huì)發(fā)生什么呢?它將在相同的數(shù)據(jù)上運(yùn)行,也應(yīng)該具有相同的行為,因此將應(yīng)用相同的測(cè)試。
僅僅復(fù)制整個(gè)TestCase 顯然不是首選的解決方案,但是通過(guò)使用setUp,我們已經(jīng)在正確的軌道上了。我們將所有測(cè)試函數(shù)轉(zhuǎn)移到一個(gè)基類(lèi)中,而將setUp保留為一個(gè)抽象函數(shù)。
- class AbstractTestVAE(unittest.TestCase):
- def setUp(self):
- raise NotImplementedError
- ... # Test functions
你的IDE會(huì)提示類(lèi)沒(méi)有成員變量net 和test_inputs,但是Python并不關(guān)心。只要子類(lèi)添加了它們,它就可以工作。對(duì)于我們想要測(cè)試的每個(gè)模型,我們創(chuàng)建這個(gè)抽象類(lèi)的一個(gè)子類(lèi),并在其中實(shí)現(xiàn)setUp。為多個(gè)模型或同一個(gè)模型的多個(gè)配置創(chuàng)建TestCases 就像:
- class TestCNNVAE(AbstractTestVAE):
- def setUp(self):
- self.test_inputs = torch.randn(4, 1, 32, 32)
- self.net = model.CNNVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
- class TestMLPVAE(AbstractTestVAE):
- def setUp(self):
- self.test_inputs = torch.randn(4, 1, 32, 32)
- self.net = model.MLPVAE(input_shape=(1, 32, 32), bottleneck_dim=16)
只剩下一個(gè)問(wèn)題了。unittest包發(fā)現(xiàn)并運(yùn)行unittest.TestCase的所有子元素。因?yàn)檫@包括不能實(shí)例化的抽象基類(lèi),所以我們總是會(huì)有一個(gè)失敗的測(cè)試。
解決方案是由一個(gè)流行的設(shè)計(jì)模式提出的。通過(guò)刪除TestCase作為AbstractTestVAE的父類(lèi),它就不再被發(fā)現(xiàn)了。相反,我們讓我們的具體測(cè)試有兩個(gè)父類(lèi), TestCase和AbstractTestVAE。抽象類(lèi)和具體類(lèi)之間的關(guān)系不再是父類(lèi)和子類(lèi)之間的關(guān)系。相反,具體類(lèi)使用抽象類(lèi)提供的共享功能。這個(gè)模式稱(chēng)為MixIn。
- class AbstractTestVAE:
- ...
- class TestCNNVAE(unittest.TestCase, AbstractTestVAE):
- ...
- class TestMLPVAE(unittest.TestCase, AbstractTestVAE):
- ...
父類(lèi)的順序很重要,因?yàn)榉椒ú檎沂菑淖蟮接疫M(jìn)行的。這意味著TestCase將覆蓋AbstractTestVAE的共享方法。在我們的例子中,這不是一個(gè)問(wèn)題,但無(wú)論如何知道都是好的。
Trainer
我們的學(xué)習(xí)系統(tǒng)的最后一部分是trainer類(lèi)。它將你所有的組件(數(shù)據(jù)集、優(yōu)化器和模型)放在一起,并使用它們來(lái)訓(xùn)練模型。此外,它還實(shí)現(xiàn)了一個(gè)評(píng)估函數(shù),輸出測(cè)試數(shù)據(jù)的平均損失。在訓(xùn)練時(shí),所有的損失和指標(biāo)都被寫(xiě)入一個(gè)TensorBoard event文件中以便可視化。
在這一部分中,編寫(xiě)可重用測(cè)試是最困難的,因?yàn)樗试S最大程度的自由實(shí)現(xiàn)。有些人只在腳本文件中使用簡(jiǎn)單的代碼進(jìn)行訓(xùn)練,有些人將其封裝在函數(shù)中,還有一些人試圖保持更面向?qū)ο蟮娘L(fēng)格。我不會(huì)判斷你喜歡哪種方式。我唯一要說(shuō)的是,在我的經(jīng)驗(yàn)中,整潔封裝的trainer類(lèi)使單元測(cè)試變得最舒適。
然而,我們會(huì)發(fā)現(xiàn)我們之前學(xué)過(guò)的一些原則在這里也適用。
trainer的損失
大多數(shù)時(shí)候,你只需要從torch上選擇一個(gè)預(yù)先實(shí)現(xiàn)的損失函數(shù)就可以了。但話說(shuō)回來(lái),你所選擇的損失函數(shù)可能無(wú)法實(shí)現(xiàn)。這種情況可能是由于實(shí)現(xiàn)相對(duì)簡(jiǎn)單,函數(shù)太小眾或者太新。無(wú)論如何,如果你自己實(shí)現(xiàn)了它,你也應(yīng)該測(cè)試它。
我們的例子使用Kulback-Leibler (KL)散度作為整體損失函數(shù)的一部分,這在PyTorch中是不存在的(現(xiàn)在的版本里有了)。我們的實(shí)現(xiàn)是這樣的:
- def _kl_divergence(log_sigma, mu):
- return 0.5 * torch.sum((2 * log_sigma).exp() + mu ** 2 - 1 - 2 * log_sigma)
函數(shù)取多變量高斯分布的標(biāo)準(zhǔn)偏差和平均值的對(duì)數(shù),并計(jì)算在封閉形式中的標(biāo)準(zhǔn)高斯分布的KL散度。
檢查這種損失的一種方法是手工計(jì)算,然后硬編碼以便比較。更好的方法是在另一個(gè)包中找到一個(gè)參考實(shí)現(xiàn),并根據(jù)它的輸出檢查代碼。幸運(yùn)的是,scipy包有一個(gè)離散KL散度的實(shí)現(xiàn),我們可以使用:
- @torch.no_grad()
- def test_kl_divergence(self):
- mu = np.random.randn(10) * 0.25 # means around 0.
- sigma = np.random.randn(10) * 0.1 + 1. # stds around 1.
- standard_normal_samples = np.random.randn(100000, 10)
- transformed_normal_sample = standard_normal_samples * sigma + mu
- bins = 1000
- bin_range = [-2, 2]
- expected_kl_div = 0
- for i in range(10):
- standard_normal_dist, _ = np.histogram(standard_normal_samples[:, i], bins, bin_range)
- transformed_normal_dist, _ = np.histogram(transformed_normal_sample[:, i], bins, bin_range)
- expected_kl_div += scipy.stats.entropy(transformed_normal_dist, standard_normal_dist)
- actual_kl_div = self.vae_trainer._kl_divergence(torch.tensor(sigma).log(), torch.tensor(mu))
- self.assertAlmostEqual(expected_kl_div, actual_kl_div.numpy(), delta=0.05)
我們首先從標(biāo)準(zhǔn)高斯函數(shù)和一個(gè)不同均值和標(biāo)準(zhǔn)差的高斯函數(shù)中抽取一個(gè)足夠大的樣本。然后我們用np.histogram函數(shù),得到基本pdf的離散逼近。有了這些,我們就可以用scipy.stats.entropy得到一個(gè)KL散度來(lái)比較。我們使用一個(gè)相對(duì)較大的delta來(lái)進(jìn)行比較,因?yàn)閟cipy.stats.entropy只是一個(gè)近似值。
你可能已經(jīng)注意到,我們沒(méi)有創(chuàng)建Trainer對(duì)象,而是使用TestCase的成員。我們?cè)谶@里使用了與模型測(cè)試相同的技巧,并在setUp函數(shù)中創(chuàng)建了它。我們還固定了PyTorch和NumPy的種子。因?yàn)槲覀冞@里不需要任何梯度,所以我們用@torch.no_grad來(lái)裝飾函數(shù)。
trainer的日志記錄
我們使用TensorBoard來(lái)記錄我們的訓(xùn)練過(guò)程的損失和度量。為此,我們希望確保按預(yù)期寫(xiě)入所有日志。一種方法是在訓(xùn)練后打開(kāi)event文件,查找正確的event。同樣,這也是一個(gè)有效的選項(xiàng),但我們將以另一種方式來(lái)看看unittest包的一個(gè)有趣功能:mock。
mock允許你用一個(gè)監(jiān)視其自身是如何調(diào)用的函數(shù)來(lái)打包一個(gè)函數(shù)或?qū)ο?。我們將替換summary writer的add_scalar 函數(shù),并確保以這種方式記錄我們關(guān)心的所有損失和指標(biāo)。
- def test_logging(self):
- with mock.patch.object(self.vae_trainer.summary, 'add_scalar') as add_scalar_mock:
- self.vae_trainer.train(1)
- expected_calls = [mock.call('train/recon_loss', mock.ANY, 0),
- mock.call('train/kl_div_loss', mock.ANY, 0),
- mock.call('train/loss', mock.ANY, 0),
- mock.call('test/loss', mock.ANY, 0)]
- add_scalar_mock.assert_has_calls(expected_calls)
assert_has_calls 函數(shù)匹配預(yù)期調(diào)用列表和實(shí)際記錄的調(diào)用。mock.ANY 表示我們不關(guān)心記錄的標(biāo)量的值,因?yàn)闊o(wú)論如何我們都不知道它。
因?yàn)槲覀儾恍枰獙?duì)整個(gè)數(shù)據(jù)集執(zhí)行完一個(gè)epoch,所以我們?cè)趕etUp 中將訓(xùn)練數(shù)據(jù)配置為只有一個(gè)batch。這樣,我們可以顯著地加快我們的測(cè)試速度。
trainer的擬合
最后一個(gè)問(wèn)題也是最難回答的。我的訓(xùn)練最終會(huì)收斂嗎?要確切地回答這個(gè)問(wèn)題,我們需要用我們所有的數(shù)據(jù)進(jìn)行一次全面的訓(xùn)練并對(duì)其打分。
由于這非常耗時(shí),我們將使用一種更快的方法。我們將看看我們的訓(xùn)練是否能使模型對(duì)單個(gè)batch的數(shù)據(jù)進(jìn)行過(guò)擬合。測(cè)試函數(shù)相當(dāng)簡(jiǎn)單:
- def test_overfit_on_one_batch(self):
- self.vae_trainer.train(500)
- self.assertGreaterEqual(30, self.vae_trainer.eval())
如前一節(jié)所述,setUp函數(shù)創(chuàng)建一個(gè)只包含一個(gè)batch的數(shù)據(jù)集的trainer。此外,我們也使用訓(xùn)練數(shù)據(jù)作為測(cè)試數(shù)據(jù)。通過(guò)這種方式,我們可以從 eval函數(shù)中獲得訓(xùn)練batch的損失,并將其與我們預(yù)期的損失進(jìn)行比較。
對(duì)于一個(gè)分類(lèi)問(wèn)題,當(dāng)我們完全過(guò)擬合時(shí),我們期望損失為零。“VAE”的問(wèn)題是,它是一個(gè)非確定性的生成模型,零損失是不現(xiàn)實(shí)的。這就是為什么我們預(yù)期的損失是30,這等于每像素的誤差為0.04。
這是迄今為止運(yùn)行時(shí)間最長(zhǎng)的測(cè)試,它可以運(yùn)行500 epochs。最后,在我的筆記本電腦上用1.5分鐘左右就可以了,這仍然是合理的。為了在不降低對(duì)沒(méi)有GPU的機(jī)器的支持的情況下進(jìn)一步加速,我們可以簡(jiǎn)單地在setUp中添加這一行:
- device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
這樣一來(lái),如果我們有GPU,我們就可以利用它,如果沒(méi)有,就利用CPU進(jìn)行訓(xùn)練。
最后要注意的
在我們進(jìn)行日志記錄時(shí),你可能會(huì)注意到,針對(duì)trainer的單元測(cè)試往往會(huì)使你的文件夾充滿(mǎn)event文件。為了避免這種情況,我們使用tempfile 包為trainer創(chuàng)建一個(gè)臨時(shí)日志目錄。測(cè)試結(jié)束后,我們只需要再次刪除它和它的內(nèi)容。為此,我們使用了孿生函數(shù)setUp,和tearDown。在每個(gè)測(cè)試函數(shù)后調(diào)用此函數(shù),清理過(guò)程簡(jiǎn)單如下:
- def tearDown(self):
- shutil.rmtree(self.log_dir)
總結(jié)
我們看完了這篇文章。讓我們?cè)u(píng)估一下我們從整個(gè)磨難中得到了什么。
我們?yōu)槲覀兊男±泳帉?xiě)的測(cè)試套件包含58個(gè)單元測(cè)試,整個(gè)運(yùn)行大約需要3.5分鐘。對(duì)于這58個(gè)測(cè)試,我們只編寫(xiě)了20個(gè)函數(shù)。所有測(cè)試都可以確定地、獨(dú)立地運(yùn)行。如果有GPU,我們可以運(yùn)行額外的測(cè)試。大多數(shù)測(cè)試,例如數(shù)據(jù)集和模型測(cè)試,可以在其他項(xiàng)目中輕松重用。我們可以通過(guò)使用:
- 子測(cè)試為我們的數(shù)據(jù)集的多種配置運(yùn)行一個(gè)測(cè)試
- setUp和tearDown函數(shù)一致地初始化和清理我們的測(cè)試
- 抽象測(cè)試類(lèi)來(lái)測(cè)試VAE的不同實(shí)現(xiàn)
- torch.no_grad裝飾器在可能的情況下禁用梯度計(jì)算
- mock模塊檢查函數(shù)是否被正確調(diào)用
最后,我希望我能夠說(shuō)服至少有人在他們的深度學(xué)習(xí)項(xiàng)目中使用單元測(cè)試。本文的配套git倉(cāng)庫(kù)可以作為起點(diǎn)。