[kaggle]Humpback Whale Identification Challenge冠军方案

Whale Recognition Model with score 0.78563

原文地址:https://www.kaggle.com/martinpiotte/whale-recognition-model-with-score-0-78563
亮蓝色字体为译者注

鲸鱼分类模型

本文记述了座头鲸挑战赛0.78563提交成绩的算法策略。
本文应该与 Bounding Box模型同时使用,它描述了如何将目标从图像中裁剪出来的策略1
为了加快运行速度,一些较慢的计算结果已经作为数据集包含在内,而不是重新计算。不过尽管这部分代码不执行,我们依然会将代码提供出来2

摘要

本文的方法是训练一个Siamese网络,稍后会详细介绍一些修改的部分。对精度提升帮助最大的部分是在训练过程中生成图像对。每次训练都使用一系列图像对(A,B),规则如下:

  • 图像对中50%来自匹配的鲸鱼,另50%来自不同的鲸鱼
  • 在每个训练时期,来自训练集的每张图片将被使用四次:匹配鲸鱼的A和B图像,不同鲸鱼的A和B图像。
  • 在训练阶段,需要选择使网络难以区分的不同鲸鱼的图像对。这是受到对抗性训练的启发:找到来自不同鲸鱼的成对图像,但模型看来却十分相似。
    在训练网络的同时实施上述策略对精度的提升最大。其他细节在某种程度上对准确率也有贡献,但影响要小得多。

概观

本文记录了提交成绩的所有细节。显然,要涵盖一切,它必须相当长。我鼓励大家直接跳到最感兴趣的地方,而不必经历一切。

内容

  1. 重复图像的识别(这边需要看的不多,继续前进)
  2. 图像预处理(只是一些常规的东西)
  3. Siamese网络架构(一些有趣的想法)
  4. 训练数据架构(大部分秘籍都在这里)
  5. 训练过程(需要很长时间,睡觉。。。。)
  6. 生成提交文件(继续睡。。。)
  7. Bootstrapping 与 ensemble(经典但是短)
  8. 可视化(每个人都喜欢!)
  9. 题外话(除非它有趣,不然为啥添加这个?)

重复图像的识别

本节介绍用于识别重复图像的启发式算法。 训练和测试集具有重复图像的事实是在文档中有提到的。有些图像是完美的二进制复制,二有些则有所改变:对比度和亮度,大小,屏蔽图例等。
如果两个图像符合以下条件,则认为它们是重复的:
1. 两张图片具有相同的感知哈希(phash);或者
2. 两张图片具有:

  • 相差最多6位的phash,并且

  • 具有相同的尺寸,并且

  • 标准化图像之间的像素均方误差低于给定阈值。

字典 p2h 为每张图片关联唯一图像ID(phash),即pic2hash。 字典 h2p 将每个图像id与要用于该哈希的首选图像相关联,即hash2pic。

# 读取图片描述
from pandas import read_csv

tagged = dict([(p,w) for _,p,w in read_csv('../input/whale-categorization-playground/train.csv').to_records()])
submit = [p for _,p,_ in read_csv('../input/whale-categorization-playground/sample_submission.csv').to_records()]
join   = list(tagged.keys()) + submit
len(tagged),len(submit),len(join),list(tagged.items())[:5],submit[:5]

(9850,
15610,
25460,
[(‘00022e1a.jpg’, ‘w_e15442c’),
(‘000466c4.jpg’, ‘w_1287fbc’),
(‘00087b01.jpg’, ‘w_da2efe0’),
(‘001296d5.jpg’, ‘w_19e5482’),
(‘0014cfdf.jpg’, ‘w_f22f3e3’)],
[‘00029b3a.jpg’,
‘0003c693.jpg’,
‘000bc353.jpg’,
‘0010a672.jpg’,
‘00119c3f.jpg’])

# 确定每个图像的大小
from os.path import isfile
from PIL import Image as pil_image
from tqdm import tqdm_notebook

def expand_path(p):
    if isfile('../input/whale-categorization-playground/train/' + p): return '../input/whale-categorization-playground/train/' + p
    if isfile('../input/whale-categorization-playground/test/' + p): return '../input/whale-categorization-playground/test/' + p
    return p

p2size = {}
for p in tqdm_notebook(join):
    size      = pil_image.open(expand_path(p)).size
    p2size[p] = size
len(p2size), list(p2size.items())[:5]

(25460,
[(‘00022e1a.jpg’, (699, 500)),
(‘000466c4.jpg’, (1050, 700)),
(‘00087b01.jpg’, (1050, 368)),
(‘001296d5.jpg’, (397, 170)),
(‘0014cfdf.jpg’, (700, 398))])

# 读取或者生成 p2h(picture to hash)
import pickle
import numpy as np
from imagehash import phash
from math import sqrt

# 对所有图像对,如果满足下列条件,则认为是重复的:
# 1) 它们具有相同的模式和大小;
# 2) 在将像素归一化为零均值和一方差之后,均方误差不超过0.1
def match(h1,h2):
    for p1 in h2ps[h1]:
        for p2 in h2ps[h2]:
            i1 =  pil_image.open(expand_path(p1))
            i2 =  pil_image.open(expand_path(p2))
            if i1.mode != i2.mode or i1.size != i2.size: return False
            a1 = np.array(i1)
            a1 = a1 - a1.mean()
            a1 = a1/sqrt((a1**2).mean())
            a2 = np.array(i2)
            a2 = a2 - a2.mean()
            a2 = a2/sqrt((a2**2).mean())
            a  = ((a1 - a2)**2).mean()
            if a > 0.1: return False
    return True

if isfile('../input/humpback-whale-identification-model-files/p2h.pickle'):
    with open('../input/humpback-whale-identification-model-files/p2h.pickle', 'rb') as f:
        p2h = pickle.load(f)
else:
    # 计算训练和测试集中每个图像的phash。
    p2h = {}
    for p in tqdm_notebook(join):
        img    = pil_image.open(expand_path(p))
        h      = phash(img)
        p2h[p] = h

    # 查找与给定phash值关联的所有图像。
    h2ps = {}
    for p,h in p2h.items():
        if h not in h2ps: h2ps[h] = []
        if p not in h2ps[h]: h2ps[h].append(p)

    # 找到所有不同的phash值
    hs = list(h2ps.keys())

    # 如果图像足够接近,则关联两个phash值 (这部分非常慢: 算法复杂度 n^2 )
    h2h = {}
    for i,h1 in enumerate(tqdm_notebook(hs)):
        for h2 in hs[:i]:
            if h1-h2 <= 6 and match(h1, h2):
                s1 = str(h1)
                s2 = str(h2)
                if s1 < s2: s1,s2 = s2,s1
                h2h[s1] = s2

    # 将相同phash的图像组合在一起,并用字符串格式的phash替换(更快,更可读)
    for p,h in p2h.items():
        h = str(h)
        if h in h2h: h = h2h[h]
        p2h[p] = h

len(p2h), list(p2h.items())[:5]

(25460,
[(‘00022e1a.jpg’, ‘b362cc79b1a623b8’),
(‘000466c4.jpg’, ‘b3cccc3331cc8733’),
(‘00087b01.jpg’, ‘bc4ed0f2a7e168a8’),
(‘001296d5.jpg’, ‘93742d9a28b35b87’),
(‘0014cfdf.jpg’, ‘d4a1dab1c49f6352’)])

# 对于每个图像ID,生成图像列表
h2ps = {}
for p,h in p2h.items():
    if h not in h2ps: h2ps[h] = []
    if p not in h2ps[h]: h2ps[h].append(p)
#注意到25460张图像是如何仅使用20913个不同的图像ID。
len(h2ps),list(h2ps.items())[:5]

(20913,
[(‘b362cc79b1a623b8’, [‘00022e1a.jpg’]),
(‘b3cccc3331cc8733’, [‘000466c4.jpg’]),
(‘bc4ed0f2a7e168a8’, [‘00087b01.jpg’, ‘7c72d707.jpg’]),
(‘93742d9a28b35b87’, [‘001296d5.jpg’]),
(‘d4a1dab1c49f6352’, [‘0014cfdf.jpg’, ‘89c94943.jpg’])])

# 展示一些重复图像
import matplotlib.pyplot as plt

def show_whale(imgs, per_row=2):
    n         = len(imgs)
    rows      = (n + per_row - 1)//per_row
    cols      = min(per_row, n)
    fig, axes = plt.subplots(rows,cols, figsize=(24//per_row*cols,24//per_row*rows))
    for ax in axes.flatten(): ax.axis('off')
    for i,(img,ax) in enumerate(zip(imgs, axes.flatten())): ax.imshow(img.convert('RGB'))

for h, ps in h2ps.items():
    if len(ps) > 2:
        print('Images:', ps)
        imgs = [pil_image.open(expand_path(p)) for p in ps]
        show_whale(imgs, per_row=len(ps))
        break

Images: [‘0c35fcb4.jpg’, ‘2d6610b9.jpg’, ‘a98bfd97.jpg’]

这里写图片描述

# 对于每个图像ID,选择首选的图像
def prefer(ps):
    if len(ps) == 1: return ps[0]
    best_p = ps[0]
    best_s = p2size[best_p]
    for i in range(1, len(ps)):
        p = ps[i]
        s = p2size[p]
        if s[0]*s[1] > best_s[0]*best_s[1]: # Select the image with highest resolution
            best_p = p
            best_s = s
    return best_p

h2p = {}
for h,ps in h2ps.items(): h2p[h] = prefer(ps)
len(h2p),list(h2p.items())[:5]

(20913,
[(‘b362cc79b1a623b8’, ‘00022e1a.jpg’),
(‘b3cccc3331cc8733’, ‘000466c4.jpg’),
(‘bc4ed0f2a7e168a8’, ‘00087b01.jpg’),
(‘93742d9a28b35b87’, ‘001296d5.jpg’),
(‘d4a1dab1c49f6352’, ‘0014cfdf.jpg’)])

图像预处理

训练前对图像进行以下操作:

  1. 如果图像在旋转集中,则旋转图像
  2. 变成黑白
  3. 进行仿射变换

图像旋转

我注意到有些照片中鲸鱼的尾巴指向下方而不是往常一样向上。每当我在训练集中遇到这样的实例(而不是在测试集中)时,我会将它添加到列表中。在训练过程中,将这些图像旋转180°使它们向上标注化。这个清单并不详尽,可能还有更多我没注意到的情况。

with open('../input/humpback-whale-identification-model-files/rotate.txt', 'rt') as f: rotate = f.read().split('\n')[:-1]
rotate = set(rotate)
rotate

{‘2b792814.jpg’,
‘2bc459eb.jpg’,
‘3401bafe.jpg’,
‘56fafc52.jpg’,
‘a492ab72.jpg’,
‘d1502267.jpg’,
‘e53d2b96.jpg’,
‘ed4f0cd5.jpg’,
‘f2ec136c.jpg’,
‘f966c073.jpg’}

def read_raw_image(p):
    img = pil_image.open(expand_path(p))
    if p in rotate: img = img.rotate(180)
    return img

p    = list(rotate)[0]
imgs = [pil_image.open(expand_path(p)), read_raw_image(p)]
show_whale(imgs)

这里写图片描述

转换为黑白

在我早期的实验中,我注意到我的模型在比较两个彩色图像或两个黑白图像时达到了大致相同的精度。 然而,将彩色图像与黑白图像进行比较精度则低得多。 最简单的解决方案是将所有图像转换为黑白图像,即使与原始彩色图像比较也不会降低精度。

放射变换

仿射变换将原始图像的矩形区域映射到分辨率为384x384x1的正方形图像(仅黑色和白色的一个通道)。 矩形区域的宽度高度纵横比为2.15,接近平均图像的宽高比。裁剪的矩形比另外一个kernel中计算出来的bounding box略大一些,因为削减获得的边缘比精确拟合获得的增益相比更有害,因此留一些空白是必须的(即用目标检测的方法得到鲸鱼的bbox时,可能会丢失边缘的一些信息,而为了保留这些这些信息而增加了一些额外的噪声是值得的 )。
在训练期间,通过缩放,移位,旋转和剪切的随机变换来进行数据增强。 测试时跳过随机变换。
最后,将图像归一化为零均值和单位方差。

# 从bounding box kernel中读取边界框数据(参见上面的参考资料)
with open('../input/humpback-whale-identification-model-files/bounding-box.pickle', 'rb') as f:
    p2bb = pickle.load(f)
list(p2bb.items())[:5]

[(‘00022e1a.jpg’, (34, 45, 682, 317)),
(‘000466c4.jpg’, (263, 309, 591, 412)),
(‘00087b01.jpg’, (-6, 2, 1028, 363)),
(‘001296d5.jpg’, (9, 21, 387, 135)),
(‘0014cfdf.jpg’, (36, 129, 636, 299))]

# 抑制导入keras时烦人的stderr输出
import sys
import platform
old_stderr = sys.stderr
sys.stderr = open('/dev/null' if platform.system() != 'Windows' else 'nul', 'w')
import keras
sys.stderr = old_stderr

import random
from keras import backend as K
from keras.preprocessing.image import img_to_array,array_to_img
from scipy.ndimage import affine_transform

img_shape    = (384,384,1) # 模型使用的图像形状
anisotropy   = 2.15 # 水平压缩比
crop_margin  = 0.05 # 在边界框周围添加余量以补偿边界框的不精确性

def build_transform(rotation, shear, height_zoom, width_zoom, height_shift, width_shift):
    """
    构建具有指定特征的变换矩阵
    """
    rotation        = np.deg2rad(rotation)
    shear           = np.deg2rad(shear)
    rotation_matrix = np.array([[np.cos(rotation), np.sin(rotation), 0], [-np.sin(rotation), np.cos(rotation), 0], [0, 0, 1]])
    shift_matrix    = np.array([[1, 0, height_shift], [0, 1, width_shift], [0, 0, 1]])
    shear_matrix    = np.array([[1, np.sin(shear), 0], [0, np.cos(shear), 0], [0, 0, 1]])
    zoom_matrix     = np.array([[1.0/height_zoom, 0, 0], [0, 1.0/width_zoom, 0], [0, 0, 1]])
    shift_matrix    = np.array([[1, 0, -height_shift], [0, 1, -width_shift], [0, 0, 1]])
    return np.dot(np.dot(rotation_matrix, shear_matrix), np.dot(zoom_matrix, shift_matrix))

def read_cropped_image(p, augment):
    """
    @param p : 要读取的图片的名称
    @param augment: 是否需要做图像增强
    @返回变换后的图像
    """
    # 如果给出了图像ID,则转换为文件名
    if p in h2p: p = h2p[p]
    size_x,size_y = p2size[p]

    # 根据边界框确定要捕获的原始图像的区域。
    x0,y0,x1,y1   = p2bb[p]
    if p in rotate: x0, y0, x1, y1 = size_x - x1, size_y - y1, size_x - x0, size_y - y0
    dx            = x1 - x0
    dy            = y1 - y0
    x0           -= dx*crop_margin
    x1           += dx*crop_margin + 1
    y0           -= dy*crop_margin
    y1           += dy*crop_margin + 1
    if (x0 < 0     ): x0 = 0
    if (x1 > size_x): x1 = size_x
    if (y0 < 0     ): y0 = 0
    if (y1 > size_y): y1 = size_y
    dx            = x1 - x0
    dy            = y1 - y0
    if dx > dy*anisotropy:
        dy  = 0.5*(dx/anisotropy - dy)
        y0 -= dy
        y1 += dy
    else:
        dx  = 0.5*(dy*anisotropy - dx)
        x0 -= dx
        x1 += dx

    # 生成变换矩阵
    trans = np.array([[1, 0, -0.5*img_shape[0]], [0, 1, -0.5*img_shape[1]], [0, 0, 1]])
    trans = np.dot(np.array([[(y1 - y0)/img_shape[0], 0, 0], [0, (x1 - x0)/img_shape[1], 0], [0, 0, 1]]), trans)
    if augment:
        trans = np.dot(build_transform(
            random.uniform(-5, 5),
            random.uniform(-5, 5),
            random.uniform(0.8, 1.0),
            random.uniform(0.8, 1.0),
            random.uniform(-0.05*(y1 - y0), 0.05*(y1 - y0)),
            random.uniform(-0.05*(x1 - x0), 0.05*(x1 - x0))
            ), trans)
    trans = np.dot(np.array([[1, 0, 0.5*(y1 + y0)], [0, 1, 0.5*(x1 + x0)], [0, 0, 1]]), trans)

    # 读取图像,转换为黑白再转换为numpy数组
    img   = read_raw_image(p).convert('L')
    img   = img_to_array(img)

    # 使用仿射变换
    matrix = trans[:2,:2]
    offset = trans[:2,2]
    img    = img.reshape(img.shape[:-1])
    img    = affine_transform(img, matrix, offset, output_shape=img_shape[:-1], order=1, mode='constant', cval=np.average(img))
    img    = img.reshape(img_shape)

    # 归一化为零均值和单位方差
    img  -= np.mean(img, keepdims=True)
    img  /= np.std(img, keepdims=True) + K.epsilon()
    return img

def read_for_training(p):
    """
    使用数据增强(随机变换)读取和预处理图像。
    """
    return read_cropped_image(p, True)

def read_for_validation(p):
    """
    在没有数据增强的情况下读取和预处理图像(用于测试)。
    """
    return read_cropped_image(p, False)

p = list(tagged.keys())[312]
imgs = [
    read_raw_image(p),
    array_to_img(read_for_validation(p)),
    array_to_img(read_for_training(p))
]
show_whale(imgs, per_row=3)

这里写图片描述

左图是原始图片。 中心图像进行测试转换。 右图增加了随机数据增强转换。

Siamese网络架构

Siamese网络通过比较两个图像来决定这两个图像是出自同一条鲸鱼还是不同的鲸鱼。 通过测试每个来自测试集的图像,与训练集中每个图片进行比较,就可以通过相似性进行排序来识别最匹配的鲸鱼。
Siamese网络有两部分组成。一个CNN将输入图像转化为描述鲸鱼的特征向量。具有相同权重的相同CNN作用于两个图像,我称这个CNN为branch model。我使用的是一个类似于ResNet的模型。
另一个模型称作head model,用于比较来自CNN的特征向量并确定鲸鱼是否匹配。

Head model

Head model比较来自branch model的特征向量,判断图片是否来自同一条鲸鱼。典型的方法是使用距离测度(如 L 1 范数)作为损失函数,但这里有几个理由去尝试不同的东西:

  • 距离测度会认为两个值为0的特征是最完美的匹配(值为0,距离测度也为0 ),而特征值很大,测度略微不同的特征将被视为良好,但不是很好,因为它们不完全相等。尽管如此,我觉得活动特征中的正信号比负信号更多,尤其是经过ReLu激活函数,距离测度会丢失概念。
  • 此外,距离测度不能提供负相关的特征,考虑一直情况,如果两个图像都具有特征X,则他们必须是相同的鲸鱼,除非它们都具有特征Y,在这种情况下X就不那么清晰了。
  • 同时,有一个隐含的假设,即交换两个图像必须产生相同的结果:如果A和B是相同的鲸鱼,那么B和A也是相同的鲸鱼。

为了解决这些问题,我做了以下处理:

  1. 对于每个特征,我计算了总和,乘积, L 1 L 2 范数( x + y , x y , | x y | , ( x y ) 2 )。
  2. 这四个值通过一个较小的神经网络传递,它可以学习如何权衡零和非零值之间的匹配。每个特征使用具有相同权重的相同神经网络。
  3. 输出是转换后的特征的加权和,带有sigmoid激活。权重的值是多余的,因为权重只是特征的缩放因子,可以由另一层学习,但是,它允许负权重,因为使用ReLu激活函数时无法产生负权重。

Branch model

Branch model是常规的CNN模型。 以下是其设计的关键要素:

  • 由于训练数据集很小,我试图保持网络参数的数量相对较小,同时保持模型有足够的表达性。例如,ResNet之类的架构比VGG类网络更合适。
  • 由于存储限制,大多数存储用于存储前馈传递的激活值,用于在反向传播期间计算梯度。使用Windows 10和GTX 1080,可提供大约6.8GB的显存,这个限制了模型的选择。

Branch model由6个Block组成,由于中间具有池化层,每个Block处理的分辨率越来越小。

  • Block 1 - 384 × 384
  • Block 2 - 96 × 96
  • Block 3 - 48 × 48
  • Block 4 - 24 × 24
  • Block 5 - 12 × 12
  • Block 6 - 6 × 6

Block 1 具有单个stride为2的卷积层,接着是2 × 2最大池化。由于分辨率高,它使用了大量的存储空间,因此为了节省后续Block的存储空间,这里做了最少的工作。
Block 2 有两个类似于VGG的3 × 3卷积。这些卷积比后续的ResNet模块更节省存储空间。请注意,在此之后,张量的尺寸为96 × 96 × 64,与初始的384 × 384 × 1图像的体积相同,因此我们可以假设没有丢失重要信息。
Block3到6执行ResNet类型的卷积,我建议阅读原始论文,其想法是使用1 × 1卷积的子块来减少特征数量,3 × 3卷积核另一个1 × 1卷积用来恢复原始特征的数量。然后将这些卷积的输出添加到原始张量(旁路连接),我再每一个block都使用这样的子块,再加上一个1 × 1卷积来增加每个池化层后的特征数。
Branch model的最后一步是全局最大池化,这可以使模型鲁棒地可以忽略侥幸的不够好的特征。

代码

以下是该模型的Keras代码

from keras import regularizers
from keras.optimizers import Adam
from keras.engine.topology import Input
from keras.layers import Activation, Add, BatchNormalization, Concatenate, Conv2D, Dense, Flatten, GlobalMaxPooling2D, Lambda, MaxPooling2D, Reshape
from keras.models import Model

def subblock(x, filter, **kwargs):
    x = BatchNormalization()(x)
    y = x
    y = Conv2D(filter, (1, 1), activation='relu', **kwargs)(y) # 减少特征数量
    y = BatchNormalization()(y)
    y = Conv2D(filter, (3, 3), activation='relu', **kwargs)(y) # 扩展特征域
    y = BatchNormalization()(y)
    y = Conv2D(K.int_shape(x)[-1], (1, 1), **kwargs)(y) # 无激活函数 # 恢复原始特征的数量
    y = Add()([x,y]) # Add the bypass connection
    y = Activation('relu')(y)
    return y

def build_model(lr, l2, activation='sigmoid'):

    ##############
    # BRANCH MODEL
    ##############
    regul  = regularizers.l2(l2)
    optim  = Adam(lr=lr)
    kwargs = {'padding':'same', 'kernel_regularizer':regul}

    inp = Input(shape=img_shape) # 384x384x1
    x   = Conv2D(64, (9,9), strides=2, activation='relu', **kwargs)(inp)

    x   = MaxPooling2D((2, 2), strides=(2, 2))(x) # 96x96x64
    for _ in range(2):
        x = BatchNormalization()(x)
        x = Conv2D(64, (3,3), activation='relu', **kwargs)(x)

    x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 48x48x64
    x = BatchNormalization()(x)
    x = Conv2D(128, (1,1), activation='relu', **kwargs)(x) # 48x48x128
    for _ in range(4): x = subblock(x, 64, **kwargs)

    x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 24x24x128
    x = BatchNormalization()(x)
    x = Conv2D(256, (1,1), activation='relu', **kwargs)(x) # 24x24x256
    for _ in range(4): x = subblock(x, 64, **kwargs)

    x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 12x12x256
    x = BatchNormalization()(x)
    x = Conv2D(384, (1,1), activation='relu', **kwargs)(x) # 12x12x384
    for _ in range(4): x = subblock(x, 96, **kwargs)

    x = MaxPooling2D((2, 2), strides=(2, 2))(x) # 6x6x384
    x = BatchNormalization()(x)
    x = Conv2D(512, (1,1), activation='relu', **kwargs)(x) # 6x6x512
    for _ in range(4): x = subblock(x, 128, **kwargs)

    x             = GlobalMaxPooling2D()(x) # 512
    branch_model  = Model(inp, x)

    ############
    # HEAD MODEL
    ############
    mid        = 32
    xa_inp     = Input(shape=branch_model.output_shape[1:])
    xb_inp     = Input(shape=branch_model.output_shape[1:])
    x1         = Lambda(lambda x : x[0]*x[1])([xa_inp, xb_inp])
    x2         = Lambda(lambda x : x[0] + x[1])([xa_inp, xb_inp])
    x3         = Lambda(lambda x : K.abs(x[0] - x[1]))([xa_inp, xb_inp])
    x4         = Lambda(lambda x : K.square(x))(x3)
    x          = Concatenate()([x1, x2, x3, x4])
    x          = Reshape((4, branch_model.output_shape[1], 1), name='reshape1')(x)

    # 使用合适的步幅,让2D卷积实现具有共享权重的特征神经网络.
    x          = Conv2D(mid, (4, 1), activation='relu', padding='valid')(x)
    x          = Reshape((branch_model.output_shape[1], mid, 1))(x)
    x          = Conv2D(1, (1, mid), activation='linear', padding='valid')(x)
    x          = Flatten(name='flatten')(x)

    # Dense layer的实现为加权和.
    x          = Dense(1, use_bias=True, activation=activation, name='weighted-average')(x)
    head_model = Model([xa_inp, xb_inp], x, name='head')

    ########################
    # SIAMESE NEURAL NETWORK
    ########################
    # 通过在每个输入图像上调用branch model来构建完整模型,
    # 然后是生成512个向量的head model.
    img_a      = Input(shape=img_shape)
    img_b      = Input(shape=img_shape)
    xa         = branch_model(img_a)
    xb         = branch_model(img_b)
    x          = head_model([xa, xb])
    model      = Model([img_a, img_b], x)
    model.compile(optim, loss='binary_crossentropy', metrics=['binary_crossentropy', 'acc'])
    return model, branch_model, head_model

model, branch_model, head_model = build_model(64e-5,0)
head_model.summary()


Layer (type)         Output Shape    Param #    Connected to

==================================================================================================
input_2 (InputLayer)      (None, 512)    0


input_3 (InputLayer)      (None, 512)    0


lambda_3 (Lambda)      (None, 512)    0       input_2[0][0]
                                input_3[0][0]


lambda_1 (Lambda)      (None, 512)    0       input_2[0][0]
                                input_3[0][0]


lambda_2 (Lambda)      (None, 512)    0       input_2[0][0]
                                input_3[0][0]


lambda_4 (Lambda)      (None, 512)    0       lambda_3[0][0]


concatenate_1 (Concatenate)   (None, 2048)    0       lambda_1[0][0]
                                lambda_2[0][0]
                               lambda_3[0][0]
                               lambda_4[0][0]


reshape1 (Reshape)     (None, 4, 512, 1)    0      concatenate_1[0][0]


conv2d_56 (Conv2D)    (None, 1, 512, 32)   160     reshape1[0][0]


reshape_1 (Reshape)    (None, 512, 32, 1)   0      conv2d_56[0][0]


conv2d_57 (Conv2D)    (None, 512, 1, 1)   33      reshape_1[0][0]


flatten (Flatten)       (None, 512)     0      conv2d_57[0][0]


weighted-average (Dense)  (None, 1)      513     flatten[0][0]

==================================================================================================
Total params: 706
Trainable params: 706
Non-trainable params: 0


from keras.utils import plot_model
plot_model(head_model, to_file='head-model.png')
pil_image.open('head-model.png')

这里写图片描述

branch_model.summary()

这部分太长了,就不贴了,直接看可视化的网络

# Oops, this is HUGE!
plot_model(branch_model, to_file='branch-model.png')
img = pil_image.open('branch-model.png')
img.resize([x//2 for x in img.size])

这里写图片描述

训练数据架构

正如摘要部分所强调的那样,这是提升模型精度最重要的部分。
我们希望Siamese网络从训练集中的所有可能的鲸鱼中挑选一条正确的鲸鱼。虽然需要给正确的鲸鱼打较高的分,但它必须同时给所有其他鲸鱼的较低的得分。将随机的鲸鱼得分降低是不够的。为了迫使所有其他鲸鱼达到低概率,训练算法需要提供给模型难度逐渐增加的图像对,这个难度是模型在任意给定的时间评估出来的。从本质上讲,我们把模型的训练当成是一种对抗训练的形式。
同时,我们希望模型识别鲸鱼而不是图片鉴于训练数据集中的图片数量很少,要模型识别波浪的形状或者飞鸟是不切实际的。为防止这种情况,提交给模型的数据必须是无偏的。如果某一张图片在负例中频繁地出现,那么模型会简单地学习,在任何图片出现时都猜错,而不是学习如何正确地比较鲸鱼。通过提供相同出现次数的具有50%正例和50%负例的每个图像,该模型就没有学习识别特定图片的动机,一次会更关注与需要识别的鲸鱼。(这里指的是不平衡的数据导致模型预测时会偏向多的那一类

图像选择

首先,我们减少训练集中图像的数量:

  • 黑名单中的图片都被删除
  • 重复的图片都被删除
  • 所有属于‘new_whale’类别的图像都被删除(triplet loss中其他类无法作为Anchor
  • 所有只有单个图像的鲸鱼都被移除

黑名单是通过发现对训练无益的图像手动构建的。比如尾巴的底面不可见。或者我们在海滩上看到只有尾巴的碎片,图中有两条鲸鱼等等,这份清单并不详尽。

with open('../input/humpback-whale-identification-model-files/exclude.txt', 'rt') as f: exclude = f.read().split('\n')[:-1]   
len(exclude)

34

show_whale([read_raw_image(p) for p in exclude], per_row=5)

这里写图片描述

匹配鲸鱼的例子

训练时使用的一半样本是一对图像。对应训练集中的每只鲸鱼,计算其图片的Derangement,使用原始顺序图片作为图片A,将Derangement作为图片B。这会创建一个随机数量的匹配图像对,每个图像只采样两次。

不同鲸鱼的例子

通过计算来自训练集的所有图片的Derangement来生成不同的鲸鱼示例,但须满足:

  • 图像对必须属于不同的鲸鱼;
  • 图像对必须让模型难以区分。

总结:模型同时接收4张图片,即2个图像对(A, B), (A’, B’),前者属于同一个鲸鱼,label为1,后者属于不同鲸鱼,label为0

以下算法用于生成图像对:

  1. 使用当前模型的状态计算每对图像之间的相似性。这个计算的复杂度为 n ( n 1 ) 2 n 为训练集图片的数量。幸运的是,只有head model才计算这个值,而且速度非常快,可以针对每个图像预先计算512维的特征向量,即复杂度为 O ( n )
  2. 对应于同一鲸鱼的图像对,相似度设置为
  3. Linear sum assignment algorithm 用于找到最难匹配的图像对。
  4. 为了使选择随机化并控制匹配难度,我们在步骤1的cost matrix中添加随机矩阵。 随机矩阵的值均匀分布在0和K之间。K越大,匹配越随机。 K越小,模型的配对就越困难。
  5. 为了在连续的epoch的产生不同匹配,矩阵中的所选条目用 覆盖以强制替代选择用于下一个匹配。

代码

上述逻辑基本由TrainingData类实现,该类实时地执行数据增强和匹配计算。

# 找到与图像ID关联的所有鲸鱼。 它可能不明确,因为重复的图像可能有不同的鲸鱼ID。
h2ws = {}
new_whale = 'new_whale'
for p,w in tagged.items():
    if w != new_whale: # 仅使用已识别的鲸鱼
        h = p2h[p]
        if h not in h2ws: h2ws[h] = []
        if w not in h2ws[h]: h2ws[h].append(w)
for h,ws in h2ws.items():
    if len(ws) > 1:
        h2ws[h] = sorted(ws)
len(h2ws)

8412

# 对于每条鲸鱼,找到明确的图像ID。
w2hs = {}
for h,ws in h2ws.items():
    if len(ws) == 1: # 仅使用明确的图片
        if h2p[h] in exclude:
            print(h) # 跳过排除的图像
        else:
            w = ws[0]
            if w not in w2hs: w2hs[w] = []
            if h not in w2hs[w]: w2hs[w].append(h)
for w,hs in w2hs.items():
    if len(hs) > 1:
        w2hs[w] = sorted(hs)
len(w2hs)

ebf094854a2bb1d6
f86bcf9487653848
d931c4768ebf9098
807b19b6766d09ce
bc984f67a31b48a6
aa557d0ad40f807f
c5313ec3c0343bcf
9dc39bb4833cc3c8
fb8c85f18c67131c
afa994d4416b2ab6
c0352f2194b7fcca
c4cc196f46bc8cce
c0c0753e9fcf4368
e5d11a1e86c47979
cdc0363cc23c3ecb
d8dc91b13fae8a18
f3ad8c8cb2b38c8c
f18c966fb836c90c
f8908e4ee223f758
a2d5d5eae64f2a01
96c949b632e90d3e
88d1a0eee07ce9da
e8d1960f60b13fca
d08f9d61729e8d61
90376cc843f6b9a3
e92d90d2616f0f9c
c96d1296e96b16b4
c7882c3359ec7327
9467679c9b638c98
f43183739a8f53c4
4239

# 获取训练图像列表,里面只保留至少有两个图像的鲸鱼
train = [] # A list of training image ids
for hs in w2hs.values():
    if len(hs) > 1:
        train += hs
random.shuffle(train)
train_set = set(train)

w2ts = {} # 将训练中的图像ID与每个鲸鱼ID相关联。
for w,hs in w2hs.items():
    for h in hs:
        if h in train_set:
            if w not in w2ts: w2ts[w] = []
            if h not in w2ts[w]: w2ts[w].append(h)
for w,ts in w2ts.items(): w2ts[w] = np.array(ts)

t2i = {} # 训练图像ID在训练集中的位置
for i,t in enumerate(train): t2i[t] = i

len(train),len(w2ts)

(6038, 1905)

from keras.utils import Sequence

# 首先尝试使用更快的lapjv解决Linear Assignment Problem。
# 在我写这篇文章时,带有自定义包的kaggle kernel无法提交。
# scipy可以当做备用,但在时间限制下运行此kernel太慢
# 使用scipy进行数据分区来作为一种解决方法。
# 因为算法复杂度为O(n^3), 分成小块会快的多,但生成的不是真正得解决方案。
try:
    from lap import lapjv
    segment = False
except ImportError:
    print('Module lap not found, emulating with much slower scipy.optimize.linear_sum_assignment')
    segment = True
    from scipy.optimize import linear_sum_assignment

class TrainingData(Sequence):
    def __init__(self, score, steps=1000, batch_size=32):
        """
        @param score 图片匹配的cost matrix
        @param steps epoch数,用来设计score matrix
        """
        super(TrainingData, self).__init__()
        self.score      = -score # 最大化分数与最小化负分数相同。
        self.steps      = steps
        self.batch_size = batch_size
        for ts in w2ts.values():
            idxs = [t2i[t] for t in ts]
            for i in idxs:
                for j in idxs:
                    self.score[i,j] = 10000.0 # 为匹配鲸鱼设置一个很大的值 - 消除了这种潜在的配对
        self.on_epoch_end()
    def __getitem__(self, index):
        start = self.batch_size*index
        end   = min(start + self.batch_size, len(self.match) + len(self.unmatch))
        size  = end - start
        assert size > 0
        a     = np.zeros((size,) + img_shape, dtype=K.floatx())
        b     = np.zeros((size,) + img_shape, dtype=K.floatx())
        c     = np.zeros((size,1), dtype=K.floatx())
        j     = start//2
        for i in range(0, size, 2):
            a[i,  :,:,:] = read_for_training(self.match[j][0])
            b[i,  :,:,:] = read_for_training(self.match[j][1])
            c[i,  0    ] = 1 # This is a match
            a[i+1,:,:,:] = read_for_training(self.unmatch[j][0])
            b[i+1,:,:,:] = read_for_training(self.unmatch[j][1])
            c[i+1,0    ] = 0 # Different whales
            j           += 1
        return [a,b],c
    def on_epoch_end(self):
        if self.steps <= 0: return # 跳过最后一个epoch
        self.steps     -= 1
        self.match      = []
        self.unmatch    = []
        if segment:
            # 使用较慢的scipy,用较小的batch
            # 因为算法复杂度为O(n^3), 小batch更快
            # 然而,这并不能找到真正的最优解,只是近似值。
            tmp   = []
            batch = 512
            for start in range(0, score.shape[0], batch):
                end = min(score.shape[0], start + batch)
                _, x = linear_sum_assignment(self.score[start:end, start:end])
                tmp.append(x + start)
            x = np.concatenate(tmp)
        else:
            _,_,x = lapjv(self.score) # 解决 linear assignment problem
        y = np.arange(len(x),dtype=np.int32)

        # 计算匹配鲸鱼的derangement
        for ts in w2ts.values():
            d = ts.copy()
            while True:
                random.shuffle(d)
                if not np.any(ts == d): break
            for ab in zip(ts,d): self.match.append(ab)

        # Construct unmatched whale pairs from the LAP solution.
        for i,j in zip(x,y):
            if i == j:
                print(self.score)
                print(x)
                print(y)
                print(i,j)
            assert i != j
            self.unmatch.append((train[i],train[j]))

        # Force a different choice for an eventual next epoch.
        self.score[x,y] = 10000.0
        self.score[y,x] = 10000.0
        random.shuffle(self.match)
        random.shuffle(self.unmatch)
        # print(len(self.match), len(train), len(self.unmatch), len(train))
        assert len(self.match) == len(train) and len(self.unmatch) == len(train)
    def __len__(self):
        return (len(self.match) + len(self.unmatch) + self.batch_size - 1)//self.batch_size
# 对一批32个随机cost matrix进行测试。
score = np.random.random_sample(size=(len(train),len(train)))
data = TrainingData(score)
(a, b), c = data[0]
a.shape, b.shape, c.shape

((32, 384, 384, 1), (32, 384, 384, 1), (32, 1))

# 第一对为匹配的鲸鱼
imgs = [array_to_img(a[0]), array_to_img(b[0])]
show_whale(imgs, per_row=2)

这里写图片描述

# 第二对为不匹配的鲸鱼
imgs = [array_to_img(a[1]), array_to_img(b[1])]
show_whale(imgs, per_row=2)

训练过程

本节介绍用于训练模型的过程。训练持续400个epoch,随着训练的进行,以下数值会发生变化:

  • 学习率
  • L 2 正则化项
  • 常数 K ,用于测量score matrix的随机分量的比例,用于匹配相似的图像,构建困难的训练样本。

该程序本身是从早期版本的模型中的许多实验,试验和错误演变而来。
用随机权重训练大型模型很困难。实际上,如果该模型最初被提供的实例太难,则它根本不会收敛。在本文中,难区分的样本属于不同鲸鱼的类似图像。更极端地说,构建一个训练数据集,其中不同鲸鱼的图片对出现比来自同一条鲸鱼的图片对更相似,使模型学会将类似的图像分类为不同的鲸鱼,和不同的图像一样的鲸鱼。
为了防止这种情况,早期训练K的值较大,使得负面实例基本上是随机的不同鲸鱼图片对。由于模型区分鲸鱼的能力增加,K逐渐减少,呈现更难的训练案列。同样,训练从没有 L 2 正则化开始。在250个epoch后,训练的准确性非常好,但也开始过拟合。此时,应用 L 2 正则化,将学习率重置为较大值,再训练150个epoch。
下表显示了学习率, L 2 正则化和随机score matrix的确切时间表。
还要注意,Linear Assignment Problem的score matrix是从第10个epoch后,每5个epoch计算一次。

Epochs LR K L 2
1-10 64e-5 + 0
11-15 64e-5 100.00 0
16-20 64e-5 63.10 0
21-25 64e-5 39.81 0
26-30 64e-5 25.12 0
31-35 64e-5 15.85 0
36-40 64e-5 10.0 0
41-45 64e-5 6.31 0
46-50 64e-5 3.98 0
51-55 64e-5 2.51 0
56-60 64e-5 1.58 0
61-150 64e-5 1.00 0
150-200 16e-5 0.50 0
201-240 4e-5 0.25 0
241-250 1e-5 0.25 0
251-300 64e-5 1.00 2e-4
301-350 16e-5 0.50 2e-4
351-390 4e-5 0.25 2e-4
391-400 1e-5 0.25 2e-4
# Keras生成器,仅评估branch model
class FeatureGen(Sequence):
    def __init__(self, data, batch_size=64, verbose=1):
        super(FeatureGen, self).__init__()
        self.data       = data
        self.batch_size = batch_size
        self.verbose    = verbose
        if self.verbose > 0: self.progress = tqdm_notebook(total=len(self), desc='Features')
    def __getitem__(self, index):
        start = self.batch_size*index
        size  = min(len(self.data) - start, self.batch_size)
        a     = np.zeros((size,) + img_shape, dtype=K.floatx())
        for i in range(size): a[i,:,:,:] = read_for_validation(self.data[start + i])
        if self.verbose > 0: 
            self.progress.update()
            if self.progress.n >= len(self): self.progress.close()
        return a
    def __len__(self):
        return (len(self.data) + self.batch_size - 1)//self.batch_size

# Keras生成器,用于评估head model上已预先计算的特征。
# 如果y为None,则仅计算cost matrix的上三角矩阵。
class ScoreGen(Sequence):
    def __init__(self, x, y=None, batch_size=2048, verbose=1):
        super(ScoreGen, self).__init__()
        self.x          = x
        self.y          = y
        self.batch_size = batch_size
        self.verbose    = verbose
        if y is None:
            self.y           = self.x
            self.ix, self.iy = np.triu_indices(x.shape[0],1)
        else:
            self.iy, self.ix = np.indices((y.shape[0],x.shape[0]))
            self.ix          = self.ix.reshape((self.ix.size,))
            self.iy          = self.iy.reshape((self.iy.size,))
        self.subbatch = (len(self.x) + self.batch_size - 1)//self.batch_size
        if self.verbose > 0: self.progress = tqdm_notebook(total=len(self), desc='Scores')
    def __getitem__(self, index):
        start = index*self.batch_size
        end   = min(start + self.batch_size, len(self.ix))
        a     = self.y[self.iy[start:end],:]
        b     = self.x[self.ix[start:end],:]
        if self.verbose > 0: 
            self.progress.update()
            if self.progress.n >= len(self): self.progress.close()
        return [a,b]
    def __len__(self):
        return (len(self.ix) + self.batch_size - 1)//self.batch_size
from keras_tqdm import TQDMNotebookCallback

def set_lr(model, lr):
    K.set_value(model.optimizer.lr, float(lr))

def get_lr(model):
    return K.get_value(model.optimizer.lr)

def score_reshape(score, x, y=None):
    """
    将packed matrix的'得分'转换为方阵。
    @param score the packed matrix
    @param x 第一张图像的特征张量
    @param y 第二张图像的张量,如果与x不同
    @结果为方阵
    """
    if y is None:
        # 当y为None时,得分是packed matrix的上三角矩阵。
        # 解包, 并转置形成对称的下三角矩阵。
        m = np.zeros((x.shape[0],x.shape[0]), dtype=K.floatx())
        m[np.triu_indices(x.shape[0],1)] = score.squeeze()
        m += m.transpose()
    else:
        m        = np.zeros((y.shape[0],x.shape[0]), dtype=K.floatx())
        iy,ix    = np.indices((y.shape[0],x.shape[0]))
        ix       = ix.reshape((ix.size,))
        iy       = iy.reshape((iy.size,))
        m[iy,ix] = score.squeeze()
    return m

def compute_score(verbose=1):
    """
    Compute the score matrix by scoring every pictures from the training set against every other picture O(n^2).
    """
    features = branch_model.predict_generator(FeatureGen(train, verbose=verbose), max_queue_size=12, workers=6, verbose=0)
    score    = head_model.predict_generator(ScoreGen(features, verbose=verbose), max_queue_size=12, workers=6, verbose=0)
    score    = score_reshape(score, features)
    return features, score

def make_steps(step, ampl):
    """
    执行训练
    @param step 训练的epoch数。
    @param ampl K值, score matrix的随机分量。
    """
    global w2ts, t2i, steps, features, score, histories

    # 打乱训练图片
    random.shuffle(train)

    # 将鲸鱼id映射到相关的训练图片的hash表上去。
    w2ts = {}
    for w,hs in w2hs.items():
        for h in hs:
            if h in train_set:
                if w not in w2ts: w2ts[w] = []
                if h not in w2ts[w]: w2ts[w].append(h)
    for w,ts in w2ts.items(): w2ts[w] = np.array(ts)

    # 将训练图片hash值映射到'train'数组中的索引
    t2i  = {}
    for i,t in enumerate(train): t2i[t] = i    

    # 计算每个图片对的匹配分数
    features, score = compute_score()

    # 训练模型'step'个epoch
    history = model.fit_generator(
        TrainingData(score + ampl*np.random.random_sample(size=score.shape), steps=step, batch_size=32),
        initial_epoch=steps, epochs=steps + step, max_queue_size=12, workers=6, verbose=0,
        callbacks=[
            TQDMNotebookCallback(leave_inner=True, metric_format='{value:0.3f}')
        ]).history
    steps += step

    # 收集历史数据
    history['epochs'] = steps
    history['ms'    ] = np.mean(score)
    history['lr'    ] = get_lr(model)
    print(history['epochs'],history['lr'],history['ms'])
    histories.append(history)
model_name = 'mpiotte-standard'
histories  = []
steps      = 0
if isfile('../input/humpback-whale-identification-model-files/mpiotte-standard.model'):
    tmp = keras.models.load_model('../input/humpback-whale-identification-model-files/mpiotte-standard.model')
    model.set_weights(tmp.get_weights())
else:
    # epoch -> 10
    make_steps(10, 1000)
    ampl = 100.0
    for _ in range(10):
        print('noise ampl.  = ', ampl)
        make_steps(5, ampl)
        ampl = max(1.0, 100**-0.1*ampl)
    # epoch -> 150
    for _ in range(18): make_steps(5, 1.0)
    # epoch -> 200
    set_lr(model, 16e-5)
    for _ in range(10): make_steps(5, 0.5)
    # epoch -> 240
    set_lr(model, 4e-5)
    for _ in range(8): make_steps(5, 0.25)
    # epoch -> 250
    set_lr(model, 1e-5)
    for _ in range(2): make_steps(5, 0.25)
    # epoch -> 300
    weights = model.get_weights()
    model, branch_model, head_model = build_model(64e-5,0.0002)
    model.set_weights(weights)
    for _ in range(10): make_steps(5, 1.0)
    # epoch -> 350
    set_lr(model, 16e-5)
    for _ in range(10): make_steps(5, 0.5)    
    # epoch -> 390
    set_lr(model, 4e-5)
    for _ in range(8): make_steps(5, 0.25)
    # epoch -> 400
    set_lr(model, 1e-5)
    for _ in range(2): make_steps(5, 0.25)
    model.save('mpiotte-standard.model')

生成提交文件

对于测试集中的每张图片,基本策略是这样的:

  1. 如果图像是来自训练集图像的一个或者多个复制,那么将鲸鱼(可能多于一个)从训练图像中添加为最佳候选者。
  2. 对于来自训练集的每个非new_whale类图像,计算图像分数,该分数是图像对的模型分数。
  3. 对于来自训练集的每条鲸鱼,计算得分最大的图像得分为这样鲸鱼。
  4. 根据阈值,添加new_whale类。
  5. 对鲸鱼分数进行降序排序。

假设没有标记错误,那么重复图像就是免费的答案。对于new_whale,算法首先选择高置信度预测,然后插入new_whale,然后插入低置信度预测。通过反复使用选择阈值,尽管大多数情况模型选择阈值作为最佳值,导致7100多个图像以new_whale类作为第一选择。以上结果可以在不向Kaggle提交预测的情况下测量。

# 在本文中不进行计算,因为它有点慢。 使用GTX 1080大约需要15分钟。
import gzip

def prepare_submission(threshold, filename):
    """
    Generate a Kaggle submission file.
    @param threshold the score given to 'new_whale'
    @param filename the submission file name
    """
    vtop  = 0
    vhigh = 0
    pos   = [0,0,0,0,0,0]
    with gzip.open(filename, 'wt', newline='\n') as f:
        f.write('Image,Id\n')
        for i,p in enumerate(tqdm_notebook(submit)):
            t = []
            s = set()
            a = score[i,:]
            for j in list(reversed(np.argsort(a))):
                h = known[j]
                if a[j] < threshold and new_whale not in s:
                    pos[len(t)] += 1
                    s.add(new_whale)
                    t.append(new_whale)
                    if len(t) == 5: break;
                for w in h2ws[h]:
                    assert w != new_whale
                    if w not in s:
                        if a[j] > 1.0:
                            vtop += 1
                        elif a[j] >= threshold:
                            vhigh += 1
                        s.add(w)
                        t.append(w)
                        if len(t) == 5: break;
                if len(t) == 5: break;
            if new_whale not in s: pos[5] += 1
            assert len(t) == 5 and len(s) == 5
            f.write(p + ',' + ' '.join(t[:5]) + '\n')
    return vtop,vhigh,pos

if False:
    # Find elements from training sets not 'new_whale'
    h2ws = {}
    for p,w in tagged.items():
        if w != new_whale: # Use only identified whales
            h = p2h[p]
            if h not in h2ws: h2ws[h] = []
            if w not in h2ws[h]: h2ws[h].append(w)
    known = sorted(list(h2ws.keys()))

    # Dictionary of picture indices
    h2i   = {}
    for i,h in enumerate(known): h2i[h] = i

    # Evaluate the model.
    fknown  = branch_model.predict_generator(FeatureGen(known), max_queue_size=20, workers=10, verbose=0)
    fsubmit = branch_model.predict_generator(FeatureGen(submit), max_queue_size=20, workers=10, verbose=0)
    score   = head_model.predict_generator(ScoreGen(fknown, fsubmit), max_queue_size=20, workers=10, verbose=0)
    score   = score_reshape(score, fknown, fsubmit)

    # Generate the subsmission file.
    prepare_submission(0.99, 'mpiotte-standard.csv.gz')

Bootstrapping 与 ensemble

mpiotte-standard.model的得分为0.766。
由于训练数据集较小,且测试集较大,因此bootstrapping算法是提高分数的良好方案。在本文中,bootstrapping意味着使用该模型自动生成额外的训练示例,并在较大的数据集上重新训练模型。在这个实验中,我选择了测试集中预测单个鲸鱼得分大于0.999999的图像(得分只是用于对鲸鱼进行排序的数字,它不是概率)。

Bootstrapping

with open('../input/humpback-whale-identification-model-files/bootstrap.pickle', 'rb') as f:
    bootstrap = pickle.load(f)
len(bootstrap), list(bootstrap.items())[:5]

(1885,
[(‘ea8f94e03ced18d2’, ‘w_0b775c1’),
(‘bd84d2b265199e65’, ‘w_e8bce8a’),
(‘afdad0a5e024bd23’, ‘w_3461d6d’),
(‘b61cc9598e4fea12’, ‘w_34c8690’),
(‘a1be8e613c8c5379’, ‘w_7554f44’)])

提交这些1885张照片的结果,显示准确率超过93%
将这些文件添加到训练集并从头开始重新运行训练会生成mpiotte-bootstrap.model。 该模型的得分略高于0.774,这里阈值为 0.989。

Ensemble

最佳分数是通过mpiotte-standard.model和mpiotte-bootstrap.model集合而获得的。这两种模型由于它们的性质而产生不同的错误,这使它们成为合奏的良好候选者:

  • standard model在最小的训练集上训练,因此具有更多的过拟合的可能性。
  • bootstrap model在更多数据上进行训练,但由于引导数据仅准确率为93%,因此标记准确性较低。

分配策略包括计算一个score matrix(或者通过训练的测试尺度),它是standard和bootstrap模型的线性组合。使用score matrix生成提交的策略不变,试验表明standard model的权重为0.45,bootstrap model的权重为0.55。
得到的整体的精度为0.78563,阈值为0.92。 值得注意的是,为什么整体的“阈值”值如此的低,这与两个模型产生不同误差的事实一致,因此整体分数通常低于单个模型,这些模型对它们的猜测非常乐观。

可视化

本节通过一些可视化来探索模型。

特征权重

如模型描述中所讨论的,head model对特征进行加权求和,允许负权重。我们可以验证我们是否看到了正负权重的组合,这些权重确认某些特征在匹配时会降低匹配鲸鱼的概率。 这可能让我们匹配单一的,单色的尾巴,这可能不太正确,因为匹配鲸鱼会涉及到多重的特征。

w = head_model.layers[-1].get_weights()[0]
w = w.flatten().tolist()
w = sorted(w)
fig, axes = plt.subplots(1,1)
axes.bar(range(len(w)), w)
plt.show()

这里写图片描述

我们还可以检查“每个特征”网络对不同功能值的行为方式。
我们期望看到的是,相等的零特征应该产生比类似的大值更小的输出。 同时,非常不同的值必须受到惩罚。

# 用线性激活函数构造head model
_, _, tmp_model = build_model(64e-5,0, activation='linear')
tmp_model.set_weights(head_model.get_weights())
# 用常数向量评估模型。
a = np.ones((21*21,512),dtype=K.floatx())
b = np.ones((21*21,512),dtype=K.floatx())
for i in range(21):
    for j in range(21):
        a[21*i + j] *= float(i)/10.0
        b[21*i + j] *= float(j)/10.0
x    = np.arange(0.0, 2.01, 0.1, dtype=K.floatx())
x, y = np.meshgrid(x, x)
z    = tmp_model.predict([a,b], verbose=0).reshape((21,21))
x.shape, y.shape, z.shape

((21, 21), (21, 21), (21, 21))

伪距离函数

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm

fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(x, y, z, cmap=cm.coolwarm)
plt.show()

这里写图片描述
不是很容易看到,但仍然是匹配具有大值的特征的最大输出(最佳鲸鱼匹配)。 匹配零获得更低的值。
只是色彩图可能更容易看到。 这证实了初步假设。

from matplotlib.colors import BoundaryNorm
from matplotlib.ticker import MaxNLocator

levels = MaxNLocator(nbins=15).tick_values(z.min(), z.max())
fig = plt.figure()
ax = fig.add_subplot(111)
cf = ax.contourf(x, y, z, levels=levels, cmap=cm.coolwarm)
plt.show()

这里写图片描述

特征激活

本节尝试重建最大化激活特征的图像。 这提供了有关特征提取过程的一些见解。
生成图像的代码是根据Francois Chollet在Deep Learning with Python中的示例进行修改而成的。

from scipy.ndimage import gaussian_filter

def show_filter(filter, blur):
    np.random.seed(1)
    noise   = 0.1 # Initial noise
    step    = 1 # Gradient step

    # 构造函数
    inp     = branch_model.layers[0].get_input_at(0)
    loss    = K.mean(branch_model.layers[-3].output[0,2:4,2:4,filter]) # Stimulate the 4 central cells
    grads   = K.gradients(loss, inp)[0]
    grads  /= K.sqrt(K.mean(K.square(grads))) + K.epsilon()
    iterate = K.function([inp],[grads])
    img     = (np.random.random(img_shape) -0.5)*noise
    img     = np.expand_dims(img, 0)

    # 使用梯度下降来形成图像
    for i in range(200):
        grads_value = iterate([img])[0]
        # Blurring a little creates nicer images by reducing reconstruction noise
        img = gaussian_filter(img + grads_value*step, sigma=blur)

    # 剪辑图像以提高对比度
    avg  = np.mean(img)
    std  = sqrt(np.mean((img - avg)**2))
    low  = avg - 5*std
    high = avg + 5*std
    return array_to_img(np.minimum(high, np.maximum(low, img))[0])

# 显示前25个特征 (全部为512个)
show_whale([show_filter(i, 0.5) for i in tqdm_notebook(range(25))], per_row=5)

这里写图片描述

题外话

训练可扩展性

如上所述,使用i7-8700 CPU和GTX 1080 GPU,训练基础模型大约需要2天,而bootstrap版本需要3天时间。超过50%的时间用于Linear Assignment Problem,因为所使用的算法具有 O ( n 3 ) 复杂性并且提供了精确的解决方案。然而,score matrix是随机的,因此投入大量时间来计算随机输入的精确解决方案是浪费的。 在本次竞赛的背景下,对运行时和小数据集没有约束,这是一个实用的选择。 然而,为了扩展这种方法,较低成本的随机匹配启发式将更有效。
训练可扩展性的另一种方法是将训练数据划分为不同的子集,每个子集被单独处理以匹配图像对。 每次计算cost matrix时,可以随机重建子集。 这不仅对 Linear Assignment Problem部分有效,而且在计算仍具有复杂度 O ( n 2 ) 的cost matrix时也是有效的。 通过将子集大小固定为合理的值,复杂度随着子集的数量线性增长,从而允许更大的训练数据集。

有趣的结果和分数

分数 描述
0.786 通过standard model与bootstrap model的线性组合获得的最佳分数
0.774 bootstrapped model
0.766 standard model
0.752 VGG架构的standard model
0.728 没有 L 2 正则化的standard model(250个epoch后的结果)
0.714 没有排除列表,旋转列表和bbox模型的标准模型(即没有对训练集进行手动判断)
0.423 提交结果只有重复图像和new_whale
0.325 提交结果只有new_whale
0.107 提交结果只有重复图像

验证集

到目前为止,我还没有讨论验证数据集。 在研究过程中,我使用了由训练集中的570个图像组成的验证集来测试想法并调整训练过程。 但是,通过使用所有数据重新训练模型,重复在验证集上成功的过程,可以实现更高的准确性。 本文基本上描述了这种最终的再训练,因此没有涉及验证集。


  1. Bounding Box模型地址:http://www.kaggle.com/martinpiotte/bounding-box-model,在这篇博客中暂不翻译
  2. 中间结果文件下载地址:https://www.kaggle.com/martinpiotte/whale-recognition-model-with-score-0-78563/data

猜你喜欢

转载自blog.csdn.net/yeahDeDiQiZhang/article/details/81450724
今日推荐