作者:SMON
鏈接:https://zhuanlan.zhihu.com/p/54172633
來源:知乎,已通過作者授權,未經允許不得二次轉載
“該比賽大多數人的做法是採用圖像分類方法,本文也是基於此。本文作者從數據處理、梯度累積trick、多gpu訓練,自己遇到的一些坑等都做了總結,並且結束後還對一些比較優秀的top方案作了詳細的對比分析,感覺作者總結的很詳細,讀後收穫很大,作者代碼見底部閱讀原文。”
1. 賽題簡述
還記得前段時間很火的微信小程序“猜畫小歌”嗎,這個的數據就來自這個小程序的網頁版Quick, Draw!,遊戲提示用戶繪製描繪特定類別的塗鴉,例如“香蕉”,“桌子”等,所以Google通過這個小遊戲收集了來自世界各地的塗鴉數據, 數據集所有信息都可見此數據集的官方倉庫。本次比賽使用了340類一共約五千萬個樣本。
主辦方給了兩個版本的訓練集,raw 和 simplified,都是以csv文件的形式給出的。raw版本就是原始收集到的數據各字段如下所示:
其中drawing字段就是塗鴉數據,包含座標和時間信息,示例如下:
[
[ // First stroke
[x0, x1, x2, x3, ...],
[y0, y1, y2, y3, ...],
[t0, t1, t2, t3, ...]
],
[ // Second stroke
[x0, x1, x2, x3, ...],
[y0, y1, y2, y3, ...],
[t0, t1, t2, t3, ...]
],
... // Additional strokes
]
raw版本的數據集很大而且很多冗餘信息,所以大多數選手(包括我)都是用的simplified版本作爲主要訓練集,simplified數據集去掉了時間信息和冗餘的座標信息(比如兩點確定一條線段,那麼線段中間的點就是冗餘的)並將座標進行了scale,具體處理方式如下:
-
scaled 的座標數據進行了左上對齊,最小值爲0最大值爲255. -
進行了重採樣使座標都是0-255的整數. -
去除冗餘座標使用的是Ramer–Douglas–Peucker算法,epsilon設爲2.0;
simplified數據集示例如下圖所示:
圖1.1
更多信息可見此數據集的官方倉庫,這裏就不贅述了。
1.2 賽題任務
本題的任務就是預測測試集塗鴉屬於哪個類別,是一個單分類問題。此題的難度在於,由於訓練數據來自遊戲本身,塗鴉樣本可能不完整而且可能與標籤不符,噪聲比較多,如圖1.2所示(圖片來源)。選手需要構建一個識別器,可以有效地從這些噪聲數據中學習得到模型。
圖1.2
上圖就是訓練集中屬於“蚊子”類別的數據示例,綠色是被標記爲"可識別"的樣本,紅色是被標記爲"不可識別"的樣本,可以看到不管是可識別還是不可識別的樣本,都存在噪聲的情況。
1.3 評價指標
雖然是一個單分類問題,但是對於每一個樣本,我們需要提交最有可能的3個預測結果(按可能性從大到小排),評價指標是Mean Average Precision @ 3 (MAP@3):
其中U是測試集樣本總數,n是每個樣本的預測值總數,p(k)是前k個結果中準確率, 具體可參見這個kernel,另外計算代碼可參考這裏。簡單點講,在提交的三個預測結果中,真實結果越靠前分數就越高。
2. 我的方案
可見此題的數據是序列數據,所以最先想到可以用RNN(參加這個比賽的很大一個原因也是因爲相對於CNN,我對RNN更熟悉),當然把數據渲染成圖片也可以用CNN。根據我的實際實驗,RNN的效果並不好(應該是我網絡結構沒設計好,討論區有人提到用RNN也可以達到不錯的效果),所以就採用了CNN相關模型,後來又merge了隊友,最終的69名(PB 0.94223)是隊友的提交結果,我的提交結果(PB 0.94185)應該是73名。
2.1 數據相關
2.1.1 打亂原始文件
題目給的數據是每一類一個csv文件,一共有340類,每一類有十多萬個樣本,即使是使用simplified數據集,總的csv文件大小也達到了20G+的級別,所以一次性讀進內存肯定是不行的。我首先採用了這個kernel將340個csv文件混合在一起然後再隨機分成100份分別存儲,這樣每一份裏面的每一個樣本的類別都是隨機的。思路就是根據key_id的值產生一個0-99的僞隨機數cv,然後cv就覺定當前樣本應該放在哪個文件,最後再將每個文件裏的樣本順序打亂。其核心代碼如下:
(1) 決定某個樣本應該存放在哪個文件:
for y, cat in tqdm(enumerate(categories)): # 共340個類別
df = s.read_training_csv(cat) # df就爲當前類別的csv
df['y'] = y # y爲 0~339 的數字,相當於對類別進行了LabelEncode
df['cv'] = (df.key_id // 10 ** 7) % NCSVS # NCSVS = 100, cv決定了應該放在哪一個文件中
for k in range(NCSVS):
filename = INPUT_PATH + '/shuffled_csv/train_%d_%d.csv'%(k+1, NCSVS)
chunk = df[df.cv == k] # 得到df中cv=k的樣本,應該存放在當前文件中
chunk = chunk.drop(['key_id'], axis=1)
if y == 0: # 新建文件
chunk.to_csv(filename, index=False)
else: # mode='a': 附加寫 方式打開文件
chunk.to_csv(filename, mode='a', header=False, index=False)
(2) 將每個文件中的樣本順序打亂:
for k in tqdm(range(NCSVS)):
filename = INPUT_PATH + '/shuffled_csv/train_%d_%d.csv'%(k+1, NCSVS)
if os.path.exists(filename):
df = pd.read_csv(filename)
df['rnd'] = np.random.rand(len(df)) # 給每個樣本一個隨機數
df = df.sort_values(by='rnd').drop('rnd', axis=1)
df.to_csv(filename + '.gz', compression='gzip', index=False) # 以壓縮的方式存儲csv
os.remove(filename)
這樣處理後,原始simplified數據集中340個共20G+的csv文件被處理成100個csv共7G+,而且每個文件裏的樣本不是屬於同一類而是隨機的,這就方便後續的數據讀取了。上面完整的代碼見我的倉庫。
2.1.2 多進程讀取
經過2.1.1處理後的數據就可以很方便地直接用了,我們可以考慮並行讀取使讀取更快:
def read_one_df_file(df_file):
"""定義一個讀取一個csv的函數,這個函數會當做參數傳入下面的並行處理的函數"""
unused_cols = ["countrycode", "recognized", "timestamp", "cv"]
name = df_file.split('_')[-2]
print('%s ' % (name), end = ' ', flush=True)
df = pd.read_csv(df_file)
drop_cols = [col for col in unused_cols if col in df.columns]
if len(drop_cols) > 0:
df = df.drop(drop_cols, axis=1)
return df
def multi_thread_read_df_files(df_files, processes=32):
"""並行讀取多個csv並組成一個大的bigdf"""
start = dt.datetime.now()
pool = Pool(processes=processes) # from multiprocessing import Pool
dfs = pool.map(read_one_df_file, df_files)
pool.close()
pool.join()
end = dt.datetime.now()
print("\nTotal time:", (end - start).seconds, "seconds")
big_df = pd.concat(dfs, ignore_index=False, sort=False)
big_df.reset_index(drop=True, inplace=True)
return big_df
上面的完整代碼見我的倉庫裏的data_loader.py,另外多進程的學習可參考此處。
2.1.3 outliers
由圖1.2知訓練數據中有很多噪聲,如何衡量噪聲並去除這些噪聲數據(outliers)呢?kernelsketch entropy提出用熵(entropy)來找出這些outliers, 即把entropy低於和高於某閾值的樣本視爲outliers。
先來看看如何計算entropy:
def entropy_it(x):
counts = np.bincount(x) # 計算x中 0-255這些數字的出現次數
p = counts[counts > 0] / float(len(x)) # 歸一化成概率
# compute Shannon entropy in bits
return -np.sum(p * np.log2(p))
直觀理解,如果一張圖片上的信息很少例如像素值幾乎完全一樣,那麼歸一化後的概率p就幾乎是一個one hot的向量,這樣-np.sum(p * np.log2(p))
就幾乎得0;相反地,如果圖片上的信息很豐富,像素分佈比較均勻,那麼p就是一個每個元素幾乎都相等的向量,這樣算出來的entropy就比較大。
例如訓練集樣本entropy低於和高於99%樣本的示例分別如下:
圖2.1
但是我最終並沒有按照此方法去掉這些“outliers”,主要是出於以下考慮:
-
訓練集很大,接近五千萬,所以數據噪聲對模型的影響應該有限; -
按照此方法可得出測試集中也有一些噪聲; -
按照此方法得出的不一定就是噪聲,例如圖2.1第一排第一個應該是‘雨滴’,最後一排第三個應該是‘龍捲風’。 -
時間不夠了,如果時間夠的話我肯定會試一下。
這個kernel的作者也在討論中提到:
Be careful, do not remove unusual samples from training. In my recipe I use a curriculum learning, i.e. increase the amount of outliers at each new epoch.
雖然我最後並沒有用這個方法,但是卻提供了一個去噪的好思路。本節的完整代碼見我的倉庫中的sketch_entropy notebook。
2.2 模型
2.2.1 模型結構
本次比賽我最後採用的結構是xception(完整代碼見此處),用的是pretrainedmodels庫,這個庫有主流模型的pytorch的實現,直接用就是,特別方便。代碼如下
from pretrainedmodels.models.xception import Xception
xception_path = "/YOUR_PATH/pytorch/xception-43020ad28.pth"
def create_model(num_classes=340, model_func=Xception, pretrained_path=xception_path):
model = model_func(num_classes=1000)
# imageNet預訓練參數,真的有用嗎?
model.load_state_dict(torch.load(pretrained_path))
# 修改最後的fc層
fc_in_feas = model.fc.in_features
model.fc = nn.Linear(fc_in_feas, num_classes)
model.last_linear = model.fc # pretrainedmodels這個包裏的模型作forward時使用的是last_linear
return model
2.2.2 梯度累積trick
我們知道,一般來說,增大batch size會使最終的預測效果變得更好,但是GPU顯存是有限的不可能無限增大batch size,這時候梯度累積就派上用場了。簡單來說,梯度累積就是累積多個batch的梯度然後一次更新參數,而不是常用的一個batch更新一次,親測在小數據集上是有效果提升的(本次比賽數據集size很大,但我也用了這個trick,沒有和不用這個trick做比較)。參考這裏Gopal_Sharma的系列回答,我寫了如下代碼:
# loss, preds, step_acc = train_step(model, inputs, labels, criterion, optimizer)
################# 將batch_accumulate_size個batch的梯度積累起來,只在最後一次更新網絡參數 ###################
inputs = inputs.to(DEVICE, dtype=torch.float)
labels = labels.to(DEVICE, dtype=torch.float)
if step % batch_accumulate_size == 0:
optimizer.zero_grad()
with torch.set_grad_enabled(True):
# forward
outputs = model(inputs)
loss = criterion(outputs, labels.long()) / batch_accumulate_size # 一定要除以這個size,原因見上面鏈接的討論
loss.backward()
_, preds = torch.max(outputs, 1)
correct_num = torch.sum(preds == labels.long())
step_acc = correct_num.double() / inputs.size(0)
if (step + 1) % batch_accumulate_size == 0:
optimizer.step() # 只在最後一次更新網絡參數
loss = batch_accumulate_size * loss.item() # 轉換爲數字方便後面用visdom畫圖
step_acc = step_acc.item()
########################################################################################
2.2.3 多GPU
pytorch實現多GPU還是比較方便的,只需要加上一行代碼即可:
model = create_model()
multi_gpu_model = nn.DataParallel(model)
需要注意的是,使用了DataParallel(model)
的模型在保存的時候會在參數的key前面加上“module.”,所以如果使用單GPU時加載多GPU保存的模型參數時會報錯KeyError: 'unexpected key "module.xx.weight" in state_dict'
,正確的處理方式如下:
from collections import OrderedDict
pretrained_net_dict = torch.load(best_model_path)
if hps.gpus == 1 and list(pretrained_net_dict.keys())[0][:6] == "module":
# 如果當前是單GPU但是保存的模型是多GPU,那就要去掉每個key的前綴"module."
new_state_dict = OrderedDict()
for k, v in pretrained_net_dict.items():
name = k[7:] # remove "module."
new_state_dict[name] = v
# load params
model.load_state_dict(new_state_dict)
else:
model.load_state_dict(pretrained_net_dict)
2.2.4 圖像數據的一個坑
本節的notebook見此處。
事情的起因是這樣的,我在訓練的時候沒有進行數據增強(只用了toTensor
和Normalize
兩個transform),而我在測試的時候想用一下用torchvision自帶的一些圖片增強的transform(例如CenterCrop、RandomHorizontalFlip、RandomRotation
等等),但是這些transform要求輸入的是PIL圖片,所以我在測試的時候就用瞭如下代碼將image array轉成了PIL圖片:
PIL_image = Image.fromarray(image_array.astype('uint8'), 'RGB')
但最後測試出來的結果與驗證時完全不一樣,模型基本上是靠猜。這是爲什麼呢?仔細看看下面這個圖就知道了。
圖 2.2
由圖2.2可知,訓練和測試的時候數據取值範圍都變了,網絡預測結果當然就很差了。先來看看ToTensor
的官方文檔:
torchvision.transforms.ToTensor
Convert a PIL Image or numpy.ndarray to tensor.
Converts a PIL Image or numpy.ndarray (H x W x C) in the range [0, 255] to a torch.FloatTensor of shape (C x H x W) in the range [0.0, 1.0].
可見ToTensor
將輸入的0-255的數據scale到0-1並且還調整了一下維度順序,那爲什麼圖2.2的訓練輸入還是0-255呢? 問題就出在數據的類型上面,訓練時,原始圖片數據iamge_array
的類型是float64(而不是ToTensor
期待的uint8),經過ToTensor
轉換後數值大小不會變依然是0-255只是調了一下維度順序。而測試的時候,由於ToPILImage
強制要求輸入類型是uint8,所以先將輸入轉換成了uint8格式,所以就正確的被scale到了0-1。
總結一下,由於像素值爲0到255的整數,所以剛好是uint8所能表示的範圍,而很多關於圖片的函數就默認輸入的是uint8型,若不是,可能不會報錯但的可能得不到想要的結果。所以,如果用像素值(0-255整數)表示圖片數據,那麼一律將其類型設置成uint8,避免不必要的bug。
3. top方案
3.1 1st place solution
第一名的方案由現在排在所有kaggle用戶第五的超級大佬Pavel Pleskov貢獻,原文見https://link.zhihu.com/?target=https%3A//www.kaggle.com/c/quickdraw-doodle-recognition/discussion/73738。
3.1.1 模型結構
剛開始,Pavel訓練了很多分類模型:resnet18, resnet34, resnet50, resnet101, resnet152, resnext50, resnext101, densenet121, densenet201, vgg11, pnasnet, incresnet, polynet, nasnetmobile, senet154, seresnet50, seresnext50, seresnext101。此外,數據預處理嘗試了1個和3個通道的輸入、image size從112逐漸增大到256。大約40個模型中,最優的模型得到了0.946的分數,這個單一模型就可以拿到金牌了。
可以看到,這種類型的比賽如果前期能花大量時間嘗試不同的模型,那基本就能保證獎牌了,甚至金牌。
3.1.2 ensemble
爲了將多個模型的輸出集成起來,粗暴一點就是直接將輸出的概率值求平均再取top3即得到最終的輸出(也就是blend)。怎樣將多個模型的結果ensemble在一起呢?因爲一共340類,所以每一個模型對每個樣本都會輸出340個概率值,如果將每個概率值作爲ensemble的feature,假設一共有8個模型,那麼一個樣本就有340x8個feature,這個特徵維度有點大了而且其中很多都是接近0的值。
Pavel的思路是:
for each sample and for each model you collect top 10 probabilities with the labels, then convert them into 10 samples with the binary outcome - whether this is a correct label or not (9 negative examples + 1 positive). It's easy to feed such a dataset to any booster because the number of features will be small (equal to the number of models).
大概意思是將ensemble轉換成一個二分類問題: 根據8(模型數)個概率值判斷是不是label(例如這8個概率值都大於0.8,那麼幾乎可以肯定這就是label)。詳細來講,LGBM的特徵維度等於模型數(每個特徵就代表一個模型的輸出概率),而樣本數將會是原始樣本數的10倍。21st place solution也用到了這個方法,比直接平均得到的分數要高。
但是我有一點不明白的是如何找這個top10,因爲對於mode1它的輸出概率值top10可能是class0-9,對於model2它的輸出概率值top10可能是class1-10,這樣就對應不上了。但是我倒是有一個思路那就是先對8個模型的輸出概率求個平均再取top10(21st place solution貌似使用的最好的model來預測出top10)。例如8個模型對於某張塗鴉圖片的平均輸出概率top10是O1、O2...O10,那麼這就能產生10個LGBM的輸入樣本,會得到10個輸出(是0-1的概率值),取最大的三個即最終這個塗鴉樣本的結果。
3.1.3 Predictions balancing trick
Heng CherKeng發現,測試集中的groundtruth分佈應該是均勻的,而且發現 (112199+1)/340=330 (訓練集樣本數加1除以類別數得330)。這一點很重要,通過對模型輸出的概率按照此規律進行後處理,平均給每個模型帶來了0.7%的提升。那麼如何進行後處理呢? Pavel給出的算法是:對於當前最多的預測類別,將所有這個類別對應的概率不斷減去一個很小的數直到這個類別不是最多的,重複上述過程直到預測類別差不多均勻。Pavel在之前的比賽也用到了這個算法(代碼見此處),代碼原理和剛剛說的略有不同,算法步驟如下:
-
先對每一種類別賦一個初始爲1的係數coefficients,當前預測概率等於實際概率乘以對應的係數; -
計算每種類別的數目,按照這個數目再計算一個score,邏輯是預測越均勻score就越大; -
對最多的類別label,執行 coefficients[label] -= alpha
; -
若還沒達到最大迭代次數,就繼續執行2、3,執行過程中記錄最大的score對應的coefficients,迭代完成後返回這個coefficients。
上述的score計算過程如下:
def _compute_score_with_coefficients(predicts, coefficients):
_, counter = _get_labels_distribution(predicts, coefficients) # 計算每種類別的數目
# 按照下面的計算過程,當預測是均勻的時,score達到最大340
score = 0.
for label in range(NCATS): # NCATS=340
score += min(NCATS * float(counter[label]) / len(predicts), 1.0)
return score
Pavel Ostyakov寫的此算法的pytorch版見此處。
3.2 5th place solution
3.2.1 總體流程
第五名的方案由Artur Ispiriants和Mykhailo Matviiv貢獻,原文見https://link.zhihu.com/?target=https%3A//www.kaggle.com/c/quickdraw-doodle-recognition/discussion/73708。
Artur Ispiriants先將每個樣本從CSV文件提取出來,每個樣本用一個二進制文件存放,這佔用了大約400G的SSD,比較耗空間,但是方便後續各種模型的使用。
Artur一共訓練了三個模型:
-
Se-Resnext50 -
DPN-92 -
Se-Resnext101
每個模型都使用了在ImageNet上預訓練的權重。圖片尺寸使用了128、192、224、256,實驗顯示圖片尺寸越大分數就越高,但是由於顯存限制,尺寸越大就越難訓練了。使用128的尺寸就能達到0.944的LB。
後來Artur Ispiriants merge了隊友Mykhailo Matviiv,LB達到了約0.948,後來又使用了完整數據集裏的時間信息:
-
延遲時間 -
每一筆的時間 -
筆畫數量
上述三個數據都被scale到0-255。使用了這三個信息後,LB提升到了0.951。
3.2.2 訓練流程
由於數據集巨大,所以訓練一個epoch就需要很長的時間,所以要經常保存模型,大約4-6個小時保存一下checkpoint。下面是Mykhailo Matviiv訓練SE-ResNext101的流程:
-
圖片尺寸取128x128訓練網絡直到收斂,這大概需要20個checkpoint,能達到0.945. -
上一步得不到提升時,使用2.2.2節的梯度累積技術使batch size達到約3-4k,繼續訓練,模型進一步提升; -
更進一步的提升就是在更大的圖片尺寸(192然後256)進行fine tune。
3.2.3 trick
比賽使用的一些trick如下:
-
經常保存checkpoint帶來的一個提升就是snapshot ensembling,即一個模型最終的預測輸出是這個模型的幾個checkpoint的綜合。最後的輸出是多個模型的平均blend。 -
僞標籤。這個方法是比賽最後階段的主要提分點。此時blend模型的分數爲0.951,使用這個輸出給測試集加上了label(預測的top1),然後(只)用這些帶label的測試集fine tune所有模型10個epoch,挑選3個最佳的epoch blend得到了0.952的LB。然後再用新的輸出給測試集加上label,再fine tune,分數就達到了0.953,然後由於種種原因沒再進行了(作者說再進行可能會進一步提升)。我認爲這個方法要避免過擬合,作者也說了要謹慎使用,可以考慮將帶僞標籤的測試集混合在訓練集進行訓練以減少過擬合的風險。
3.3 其他
3.3.1 RNN模型
單模型0.941的RNN模型:1d CNN+LSTM
3.3.2 11th place solution
資源有限的情況下達到第11名的方案。
3.3.3 21st place solution
也使用了3.1.2所示的ensemble方法,評論處有具體的ensemble方法。
3.3.4 24th place solution
24th solution, 用的keras,並用了keras封裝好的梯度累積trick
3.3.5 10 lessons
46th的團隊在這裏給出了參加這個比賽得出的10條教訓,有興趣的可以去看看。
4. 總結
-
比賽前期應該多多嘗試不同的網絡結構; -
這種超大數據集的圖像類比賽,一般來講就是網絡越深、圖片尺寸越大、batchsize越大,效果就越好; -
注意積累一些trick,例如梯度累積、僞標籤、Predictions balancing等等; -
由於訓練一個epoch時間漫長,所以每隔幾個小時就保存一下模型是很有必要的,一個模型的預測輸出應該是該模型幾個checkpoint的輸出的融合; -
多逛逛討論區。 -
沒卡的話就不要玩這種超大數據集的圖像類比賽了
喜歡的話點個在看吧👇
本文分享自微信公衆號 - AI成長社(ai-growth)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。