English (unofficial) translations of posts at kexue.fm
Source

Image Classification Based on Fine-tuning (Baidu Dog Classification Competition)

Translated by DeepSeek V4 Pro. Translations can be inaccurate, please refer to the original post for important stuff.

baidu_jingsai

In the past two years, Baidu’s big data competitions were focused on Natural Language Processing. This year, the style has shifted toward fine-grained image classification. The task is to classify pet dogs into one of 100 categories. This task itself is quite ordinary, and the approach is conventional, involving nothing more than three aspects: data augmentation, fine-tuning ImageNet models, and model ensemble. I am not particularly good at model ensemble, so I only performed the first two steps. My results were average (accuracy around 80%). However, I feel that some of the code might be helpful to readers, so I am sharing it here. I will explain it alongside the code.

Competition Website (may expire at any time): http://js.baidu.com

Model

The model is primarily implemented using TensorFlow and Keras. First, we import the necessary modules:

#! -*- coding:utf-8 -*-

import numpy as np
from scipy import misc
import tensorflow as tf
from keras.applications.xception import Xception,preprocess_input
from keras.layers import Input,Dense,Lambda,Embedding
from keras.layers.merge import multiply
from keras import backend as K
from keras.models import Model
from keras.optimizers import SGD
from tqdm import tqdm
import glob
np.random.seed(2017)
tf.set_random_seed(2017)

Next is the model architecture. The base model is Xception. I used a GLU (Gated Linear Unit) activation function to compress features, followed by a Softmax layer for classification. Additionally, I added center loss and an auxiliary loss (direct connection) as auxiliary components, which can be viewed as regularization terms.

img_size = 299 # Define parameters
nb_classes = 100
batch_size = 32
feature_size = 64 # I believe for center loss, the feature size should be smaller than nb_classes to be meaningful

input_image = Input(shape=(img_size,img_size,3))
# Base model is Xception, loading pre-trained ImageNet weights, excluding the top fully connected layer
base_model = Xception(input_tensor=input_image, weights='imagenet', include_top=False, pooling='avg') 

for layer in base_model.layers: # Freeze all layers in Xception
    layer.trainable = False

dense = Dense(feature_size)(base_model.output)
gate = Dense(feature_size, activation='sigmoid')(base_model.output)
feature = multiply([dense,gate]) # These three steps constitute the GLU activation function
predict = Dense(nb_classes, activation='softmax', name='softmax')(feature) # Classification
auxiliary = Dense(nb_classes, activation='softmax', name='auxiliary')(base_model.output) # Direct connection classification

input_target = Input(shape=(1,))
centers = Embedding(nb_classes, feature_size)(input_target)
# Define center loss
l2_loss = Lambda(lambda x: K.sum(K.square(x[0]-x[1][:,0]), 1, keepdims=True), name='l2')([feature,centers]) 

Regarding the training strategy, the training is divided into three steps:

1. Freeze all Xception parameters, training only the additional fully connected layers and the center loss part using the Adam optimizer.

2. Unfreeze two blocks of Xception and fine-tune using SGD.

3. Remove most of the data augmentation and continue fine-tuning with SGD.

The code is as follows:

model_1 = Model(inputs=[input_image,input_target], outputs=[predict,l2_loss,auxiliary])
model_1.compile(optimizer='adam', 
                loss=['sparse_categorical_crossentropy',lambda y_true,y_pred: y_pred,'sparse_categorical_crossentropy'], 
                loss_weights=[1.,0.25,0.25], 
                metrics={'softmax':'accuracy','auxiliary':'accuracy'})
model_1.summary() # First stage model, optimized with Adam

for i,layer in enumerate(model_1.layers):
    if 'block13' in layer.name:
        break

for layer in model_1.layers[i:len(base_model.layers)]: # These loops unfreeze the parameters of two blocks
    layer.trainable = True

sgd = SGD(lr=1e-4, momentum=0.9) # Define low learning rate SGD optimizer
model_2 = Model(inputs=[input_image,input_target], outputs=[predict,l2_loss,auxiliary])
model_2.compile(optimizer=sgd, 
                loss=['sparse_categorical_crossentropy',lambda y_true,y_pred: y_pred,'sparse_categorical_crossentropy'], 
                loss_weights=[1.,0.25,0.25], 
                metrics={'softmax':'accuracy','auxiliary':'accuracy'})
model_2.summary() # Second stage model, optimized with SGD

model = Model(inputs=input_image, outputs=[predict,auxiliary]) # Model used for prediction
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

Augmentation

Next is the data preparation for this competition. The organizers eventually provided 18,000 training images.

import pandas as pd
# txt records the category of each image
train_txt = pd.read_csv('../train.txt', delimiter=' ', header=None)[[0,1]] 
# The categories in the txt may have gaps; they need to be mapped to continuous IDs
myid2typeid = dict(enumerate(train_txt[1].unique())) 
typeid2myid = {j:i for i,j in myid2typeid.items()}
train_txt[1] = train_txt[1].apply(lambda s: typeid2myid[s])
train_txt = train_txt.sample(frac=1) # Shuffle the training dataset
train_txt.index = range(len(train_txt))
train_imgs = list(train_txt[0])

train_txt = dict(list(train_txt.groupby(1)))
train_data,valid_data = {},pd.DataFrame()
train_frac = 0.9 # Split a validation set
for i,j in train_txt.items(): # Take 10% from each class as validation data
    train_data[i] = j[:int(len(j)*train_frac)]
    valid_data = valid_data.append(j[int(len(j)*train_frac):], ignore_index=True)

Below is some data augmentation code, written from scratch without using existing libraries. The advantage is high customizability. Of course, it is uncertain whether every one of these augmentation methods improves the results.

# Define interpolation method. Originally defined as a function to randomly use different methods, but randomness was later removed.
def interp_way():
    return 'nearest'

def random_reverse(x): # Random horizontal flip with 0.5 probability
    if np.random.random() > 0.5:
        x = x[:,::-1]
    return x

def random_rotate(x): # Random rotation between -10 and 10 degrees
    angle = 10
    r = (np.random.random()*2-1)*angle
    return misc.imrotate(x, r, interp=interp_way())

def Zoom(x, random=True): # Zoom function
    if random: # Random scaling
        r = np.random.random()*0.4+0.8 # Random scale ratio between 0.8 and 1.2
        img_size_ = int(img_size*r)
        x = misc.imresize(x, (img_size_,img_size_), interp=interp_way())
        idx,idy = np.random.randint(0, np.abs(img_size_-img_size)+1, 2)
        if r >= 1.: # If zooming in, randomly crop a patch
            return x[idx:idx+img_size,idy:idy+img_size]
        else: # If zooming out, randomly read a training image and paste the scaled image onto it
            x_ = misc.imresize(misc.imread('../train/%s.jpg'%np.random.choice(train_imgs)), (img_size,img_size))
            x_[idx:idx+img_size_,idy:idy+img_size_] = x
            return x_
    else: # If not random, resize directly to standard dimensions
        x = misc.imresize(x, (img_size,img_size), interp=interp_way())
        return x

# Code to implement random stitching of two photos of the same class.
# Based on the idea that "stitching same-class images results in the same class" to create diverse samples.
# Four stitching methods: two diagonal, horizontal, and vertical, chosen randomly.
cross1 = np.tri(img_size,img_size)
cross2 = np.rot90(cross1)
cross1 = np.expand_dims(cross1, 2)
cross2 = np.expand_dims(cross2, 2)
def random_combine(x,y):
    r,idx = np.random.random(),np.random.randint(img_size/4, img_size*3/4)
    if r > 0.75:
        return np.vstack((x[:idx],y[idx:]))
    elif r > 0.5 :
        return np.hstack((x[:,:idx],y[:,idx:]))
    elif r > 0.25:
        return cross1*x + (1-cross1)*y
    else:
        return cross2*x + (1-cross2)*y

M1 = np.ones((img_size,img_size))
M1[:img_size/2,:img_size/2] = 0
M2 = np.expand_dims(np.rot90(M1, 1), 2)
M3 = np.expand_dims(np.rot90(M1, 2), 2)
M4 = np.expand_dims(np.rot90(M1, 3), 2)
M1 = np.expand_dims(M1, 2)
def random_mask(x, p=0.5): # Randomly mask 1/4 of the image, similar to Dropout
    r = np.random.random()
    s = p/4
    if r > 1-s:
        return M1*x
    elif r > 1-s*2:
        return M2*x
    elif r > 1-s*3:
        return M3*x
    elif r > 1-s*4:
        return M4*x
    else:
        return x

def center_crop(x): # Crop the center of the image, used during prediction
    idx = np.abs(x.shape[1]-x.shape[0])/2
    if x.shape[0] < x.shape[1]:
        return x[:, idx:x.shape[0]+idx]
    else:
        return x[idx:x.shape[1]+idx, :]

def random_imread(img_path, rotate=True): # Image reading function combining the above augmentations
    img = misc.imread(img_path)
    img = center_crop(img)
    if rotate:
        img = Zoom(random_rotate(img), True)
    else:
        img = Zoom(img, True)
    return random_reverse(img).astype(float)

def just_imread(img_path): # Image reading function without data augmentation
    img = misc.imread(img_path)
    img = center_crop(img)
    img = Zoom(img, False).astype(float)
    return img

Iterators

We write iterators to generate batches for training and testing:

result_filename = '__test_result_2_2245.txt' # Prediction result file used for transfer learning
choice_weights = np.array([1.*len(train_txt[i]) for i in range(nb_classes)])
choice_weights /= choice_weights.sum() # Define weights for each class
def train_data_generator(stage,train_data): # Generator for training set
    if stage == 'Train_DA': # Training stage with data augmentation
        _ = {}
        for i,j in train_data.items():
            j_ = j.copy()
            j_[0] = zip(j[0].sample(frac=1),j[0].sample(frac=1))
            _[i] = j.append(j_, ignore_index=True)
        train_data = _ # Pre-organize image pairs for random stitching
        while True:
            _ = np.random.choice(nb_classes, batch_size/2, False, choice_weights) # Select classes
            batch = pd.DataFrame()
            for idx in _: # Select two samples per class
                batch = batch.append(train_data[idx].sample(2), ignore_index=True)
            x,y = [],[]
            for i,(img_path,myid) in batch.iterrows():
                if len(img_path) == 2: # Random stitching case
                    img1,img2 = just_imread('../train/%s.jpg'%img_path[0]),just_imread('../train/%s.jpg'%img_path[1])
                    x.append(random_combine(img1, img2)) # No other augmentation for stitched images
                else:
                    img = random_mask(random_imread('../train/%s.jpg'%img_path)) # Full random augmentation
                    x.append(img)
                y.append([myid])
            x,y = np.array(x),np.array(y)
            yield [preprocess_input(x),y], [y,y,y] # Construct output required by Keras model
    elif stage == 'Train': # Training stage with reduced augmentation
        while True:
            _ = np.random.choice(nb_classes, batch_size/2, False, choice_weights)
            batch = pd.DataFrame()
            for idx in _:
                batch = batch.append(train_data[idx].sample(2), ignore_index=True)
            x,y = [],[]
            for i,(img_path,myid) in batch.iterrows(): # Masking and stitching removed
                img = random_imread('../train/%s.jpg'%img_path, False)
                x.append(img)
                y.append([myid])
            x,y = np.array(x),np.array(y)
            yield [preprocess_input(x),y], [y,y,y]
    elif stage == 'Transfer_DA': # Transfer learning stage, includes test set predictions
        train_data = train_txt.copy()
        test_result = pd.read_csv(result_filename, delimiter='\t', header=None)[[1,0]]
        test_result.columns = [0,1]
        test_result[1] = test_result[1].apply(lambda s: typeid2myid[s])
        for i,j in test_result.groupby(1):
            train_data[i] = train_data[i].append(j, ignore_index=True)
        _ = {}
        for i,j in train_data.items():
            j_ = j.copy()
            j_[0] = zip(j[0].sample(frac=1),j[0].sample(frac=1))
            _[i] = j.append(j_, ignore_index=True)
        train_data = _
        while True:
            _ = np.random.choice(nb_classes, batch_size/2, False, choice_weights)
            batch = pd.DataFrame()
            for idx in _:
                batch = batch.append(train_data[idx].sample(2), ignore_index=True)
            x,y = [],[]
            for i,(img_path,myid) in batch.iterrows():
                if len(img_path) == 2:
                    # Manually copy all test images to the train directory
                    img1,img2 = just_imread('../train/%s.jpg'%img_path[0]),just_imread('../train/%s.jpg'%img_path[1])
                    x.append(random_combine(img1, img2))
                else:
                    img = random_mask(random_imread('../train/%s.jpg'%img_path))
                    x.append(img)
                y.append([myid])
            x,y = np.array(x),np.array(y)
            yield [preprocess_input(x),y], [y,y,y]
    elif stage == 'Transfer':
        train_data = train_txt
        test_result = pd.read_csv(result_filename, delimiter='\t', header=None)[[1,0]]
        test_result.columns = [0,1]
        test_result[1] = test_result[1].apply(lambda s: typeid2myid[s])
        for i,j in test_result.groupby(1):
            train_data[i] = train_data[i].append(j, ignore_index=True)
        while True:
            _ = np.random.choice(nb_classes, batch_size/2, False, choice_weights)
            batch = pd.DataFrame()
            for idx in _:
                batch = batch.append(train_data[idx].sample(2), ignore_index=True)
            x,y = [],[]
            for i,(img_path,myid) in batch.iterrows():
                img = random_imread('../train/%s.jpg'%img_path, False)
                x.append(img)
                y.append([myid])
            x,y = np.array(x),np.array(y)
            yield [preprocess_input(x),y], [y,y,y]

def valid_data_generator(): # Generator for validation set
    x,y = [],[]
    for i,(img_path,myid) in valid_data.iterrows():
        img = just_imread('../train/%s.jpg'%img_path)
        x.append(img)
        y.append(myid)
        if len(x) == batch_size:
            yield preprocess_input(np.array(x)), np.array(y)
            x,y = [],[]
    if x:
        yield preprocess_input(np.array(x)), np.array(y)

test_imgs = glob.glob('../test/*.jpg')
test_imgs = [i.replace('../test/','').replace('.jpg','') for i in test_imgs]
def test_data_generator(): # Generator for test set
    x = []
    for img_path in test_imgs:
        img = just_imread('../test/%s.jpg'%img_path)
        x.append(img)
        if len(x) == batch_size:
            yield preprocess_input(np.array(x))
            x = []
    if x:
        yield preprocess_input(np.array(x))

Training

First, we train the model, then use it to predict. Next, we mix the prediction results with the training set to retrain, obtaining new predictions, which can be iteratively mixed again. This is a transfer learning (pseudo-labeling) idea that can improve performance by about 0.5%–1%.

Additionally, since the model has two predictions—one using GLU features and one directly using Xception features—we can perform a weighted average of the two results to improve prediction accuracy. The weights are determined by the validation set.

if __name__ == '__main__':

    # Training process
    train_epochs = 30
    alpha = 0.5
    for i in range(train_epochs):
        print 'train epoch %s working ...'%i
        if i < 10: # First stage training
            model_1.fit_generator(train_data_generator('Train_DA',train_data), steps_per_epoch=200, epochs=3)
            if i < 9:
                continue
        elif i < 20: # Second stage training
            model_2.fit_generator(train_data_generator('Train_DA',train_data), steps_per_epoch=200, epochs=3)
        else: # Third stage training
            model_2.fit_generator(train_data_generator('Train',train_data), steps_per_epoch=200, epochs=3)
        valid_x_0 = []
        valid_x_1 = []
        valid_y = []
        for x,y in tqdm(valid_data_generator()):
            _ = model.predict(x)
            valid_x_0.append(_[0])
            valid_x_1.append(_[1])
            valid_y.append(y)
        valid_x = np.vstack(valid_x_0),np.vstack(valid_x_1)
        valid_y = np.hstack(valid_y)
        total = 1.*len(valid_x[0])
        right_0 = (valid_y == valid_x[0].argmax(axis=1)).sum()
        right_1 = (valid_y == valid_x[1].argmax(axis=1)).sum()
        acc_0 = right_0/total
        acc_1 = right_1/total
        # Exhaustive search for best weight
        right_2 = [(valid_y == ((h/100.)*valid_x[0]+(1-h/100.)*valid_x[1]).argmax(axis=1)).sum() for h in range(101)] 
        acc_2 = np.max(right_2)/total
        alpha = np.argmax(right_2)/100.
        print 'epoch %s, acc_0 %s, acc_1 %s, acc_2 %s'%(i, acc_0, acc_1, acc_2)
        model_1.save_weights('main_%s_%s_%s_%s_%s.model'%(i, int(acc_0*10000), int(acc_1*10000), int(acc_2*10000), int(alpha*10000)))

    # Transfer process. Cannot run simultaneously with training. 
    # Usually run training first, then set train_epochs=0 above and train_epochs=30 here.
    train_epochs = 30*0
    if train_epochs > 0:
        model_1.load_weights('__main_29_8165_8075_8165_10000_2289.model')
    for i in range(train_epochs):
        print 'transfer epoch %s working ...'%i
        if i < 10:
            model_2.fit_generator(train_data_generator('Transfer_DA',train_data), steps_per_epoch=200, epochs=3)
        elif i < 20:
            model_2.fit_generator(train_data_generator('Transfer',train_data), steps_per_epoch=200, epochs=3)
        else:
            model_1_.fit_generator(train_data_generator('Train',train_data), steps_per_epoch=200, epochs=3)
        model_1.save_weights('main_t1_%s.model'%i)

    # Testing process. Generate three prediction results to compare accuracy.
    test_result_0 = []
    test_result_1 = []
    test_result_2 = []
    for x in tqdm(test_data_generator()):
        _ = model.predict(x)
        test_result_0.extend([myid2typeid[i] for i in _[0].argmax(axis=1)])
        test_result_1.extend([myid2typeid[i] for i in _[1].argmax(axis=1)])
        test_result_2.extend([myid2typeid[i] for i in (alpha*_[0]+(1-alpha)*_[1]).argmax(axis=1)])

    test_result_0 = pd.DataFrame(test_result_0)
    test_result_0[1] = test_imgs
    test_result_0.to_csv('test_result_0.txt', index=None, header=None, sep='\t')

    test_result_1 = pd.DataFrame(test_result_1)
    test_result_1[1] = test_imgs
    test_result_1.to_csv('test_result_1.txt', index=None, header=None, sep='\t')

    test_result_2 = pd.DataFrame(test_result_2)
    test_result_2[1] = test_imgs
    test_result_2.to_csv('test_result_2.txt', index=None, header=None, sep='\t')

Summary

Although the article is long, most of it consists of code. From the perspective of performance, it serves as a baseline, intended for beginners to learn. Experts are welcome to leave comments and advice. Thank you.

Full code is available at: https://github.com/bojone/baidu_dog_classifier

Original address: https://kexue.fm/archives/4611

For more details on reprinting, please refer to: Scientific Space FAQ