Существует множество статей, в которых рассказывается о том, как построить простую сиамскую сеть для обнаружения дубликатов.

  1. https://towardsdatascience.com/one-shot-learning-with-siamese-networks-using-keras-17f34e75bb3d
  2. https://github.com/MahmoudWahdan/Siamese-Sentence-Similarity
  3. https://medium.com/mlreview/implementing-malstm-on-kaggles-quora-question-pairs-competition-8b31b0b16a07

Но многие из этих статей имеют вспомогательные коды либо в Keras, либо в Tensorflow. Рабочий процесс на основе MxNet для сиамского языка не очень хорошо документирован, особенно для текста. В этой статье я предоставляю код для создания простой сиамской сети с использованием MxNet — обучение и тестирование, выполненные на наборе данных вопросов и ответов Quora. Я также показал, как MxNet может выполняться на машинах с графическим процессором, что также не очень хорошо задокументировано.

Сиамская сеть

Вдохновленная командой-победителем в конкурсе Kaggle по выявлению похожих пар предложений банка Quora-Questions, в этой работе представлена ​​простая сиамская сетевая модель, сгенерированная в MxNet, которая находит похожие пары предложений с целью дедупликации.
< br /> Что считается сиамской сетью?

Любая сеть, имеющая две или более идентичных подсети, называется сиамской сетью. Такие сети, кажется, хорошо справляются с задачами сходства, такими как сходство предложений, распознавание поддельных подписей и т. д.

Сиамские сети также используются для выполнения так называемого обучения одним выстрелом. Преимущество использования сиамской сети для однократного обучения состоит в том, что она помогает изучить функцию подобия, используя ограниченные размеченные данные. Есть много приложений, которые могут извлечь выгоду из таких моделей.

Набор данных

Чтобы объяснить сиамскую сеть, я использую набор данных Quora, выпущенный в рамках конкурса данных Kaggle. Этот набор данных состоит из двух текстовых файлов — один для обучения, а другой для тестирования. Каждая строка в наборе данных состоит из трех столбцов — столбца для вопроса 1, другого для вопроса 2 и столбца индикатора, который, если он установлен на 1, указывает, что вопрос 1 похож на вопрос 2, 0 в противном случае.

Сиамская архитектура

Простая сиамская архитектура учится вычислять меру расстояния между двумя похожими предложениями в пространстве вложений. Архитектура требует, чтобы веса прерывистых скрытых слоев распределялись между двумя предложениями, которые поступают в сеть для обучения. Любая мера расстояния (евклидово расстояние, косинусное расстояние, манхэттенское расстояние) может быть использована для определения порога подобия.

Архитектура простой сиамской сети выглядит следующим образом

Слой внедрения, слой LSTM и плотный слой имеют общие веса. Оба предложения проходят через одни и те же уровни.

Хотя сама архитектура проста в реализации, существует несколько этапов предварительной обработки, которые необходимо пройти данным, прежде чем они будут переданы в качестве входных данных в сеть.

Этапы предварительной обработки

Ниже описаны некоторые основные шаги, предпринятые для очистки данных. Также показан метод создания вложений слов из векторов перчаток. Эти шаги ни в коем случае не совершенны. Есть несколько улучшений, которые можно и нужно сделать выше этого, чтобы улучшить производительность модели. Одним из них, например, является процесс размещения сущностей. Второй — лучший способ вычислить встраивание неизвестных слов.

Примечание. Приведенный ниже код относится к https://medium.com/mlreview/implementing-malstm-on-kaggles-quora- пары вопросов-конкурс-8b31b0b16a07

#this function cleans the text of all punctuations, expands the short forms
def text_to_word_list(text):
    ''' Pre process and convert texts to a list of words '''
    text = str(text)
    text = text.lower()
    # Clean the text
    text = re.sub(r"[^A-Za-z0-9^,!.\/'+-=]", " ", text)
    text = re.sub(r"what's", "what is ", text)
    text = re.sub(r"\'s", " ", text)
    text = re.sub(r"\'ve", " have ", text)
    text = re.sub(r"can't", "cannot ", text)
    text = re.sub(r"n't", " not ", text)
    text = re.sub(r"i'm", "i am ", text)
    text = re.sub(r"\'re", " are ", text)
    text = re.sub(r"\'d", " would ", text)
    text = re.sub(r"\'ll", " will ", text)
    text = re.sub(r",", " ", text)
    text = re.sub(r"\.", " ", text)
    text = re.sub(r"!", " ! ", text)
    text = re.sub(r"\/", " ", text)
    text = re.sub(r"\^", " ^ ", text)
    text = re.sub(r"\+", " + ", text)
    text = re.sub(r"\-", " - ", text)
    text = re.sub(r"\=", " = ", text)
    text = re.sub(r"'", " ", text)
    text = re.sub(r"(\d+)(k)", r"\g<1>000", text)
    text = re.sub(r":", " : ", text)
    text = re.sub(r" e g ", " eg ", text)
    text = re.sub(r" b g ", " bg ", text)
    text = re.sub(r" u s ", " american ", text)
    text = re.sub(r"\0s", "0", text)
    text = re.sub(r" 9 11 ", "911", text)
    text = re.sub(r"e - mail", "email", text)
    text = re.sub(r"j k", "jk", text)
    text = re.sub(r"\s{2,}", " ", text)
    text = text.split()
    return text
EMBEDDING_FILE = 'GoogleNews-vectors-negative300.bin.gz'
#load stopwords from nltk
stops = set(stopwords.words('english'))
#load word vectors using gensim
word2vec = KeyedVectors.load_word2vec_format(EMBEDDING_FILE, binary=True)
# Prepare embedding
vocabulary = dict()
inverse_vocabulary = ['<unk>']  # '<unk>' will never be used, it is only a placeholder for the [0, 0, ....0] embedding
questions_cols = ['question1', 'question2']
# Iterate over the questions only of both training and test datasets
for dataset in [train_df, test_df]:
    for index, row in dataset.iterrows():
        # Iterate through the text of both questions of the row
        for question in questions_cols:
            q2n = []  # q2n -> question numbers representation
            for word in text_to_word_list(row[question]):
                # Check for unwanted words
                if word in stops and word not in word2vec.vocab:
                    continue
                if word not in vocabulary:
                    vocabulary[word] = len(inverse_vocabulary)
                    q2n.append(len(inverse_vocabulary))
                    inverse_vocabulary.append(word)
                else:
                    q2n.append(vocabulary[word])
            # Replace questions as word to question as number representation
            dataset.set_value(index, question, q2n)
#each token in a sentence now needs to reflect its embeddings .. Also the sequence
# in the sentence needs to be padded 
#Pad the sequences to maxlen.
#if sentences is greater than maxlen, truncates the sentences
#if sentences is less the 500, pads with value 0 (most commonly occurrning word)
def pad_sequences(sentences,maxlen=500,value=0):
    """
    Pads all sentences to the same length. The length is defined by maxlen.
    Returns padded sentences.
    """
    padded_sentences = []
    for sen in sentences:
        new_sentence = []
        if(len(sen) > maxlen):
            new_sentence = sen[:maxlen]
            padded_sentences.append(new_sentence)
        else:
            num_padding = maxlen - len(sen)
            new_sentence = np.append(sen,[value] * num_padding)
            padded_sentences.append(new_sentence)
    return padded_sentences
#generate the embeddings of all the words in teh vocabulary
embedding_dim = 300
embeddings = 1 * np.random.randn(len(vocabulary) + 1, embedding_dim)  # This will be the embedding matrix
embeddings[0] = 0  # So that the padding will be ignored
# Build the embedding matrix
for word, index in vocabulary.items():
    if word in word2vec.vocab:
        embeddings[index] = word2vec.word_vec(word)
#make sure you release mem of word2vec 
del word2vec
 
 #generate the maximum length of the sequence 
 max_seq_length = max(train_df.question1.map(lambda x: len(x)).max(),
                     train_df.question2.map(lambda x: len(x)).max(),
                     test_df.question1.map(lambda x: len(x)).max(),
                     test_df.question2.map(lambda x: len(x)).max())
print(max_seq_length)
# Split to train validation
validation_size = 80000
training_size = len(train_df) - validation_size
X = train_df[questions_cols]
Y = train_df['is_duplicate']
#split into training and test
X_train, X_validation, Y_train, Y_validation = train_test_split(X, Y, test_size=validation_size)
# Split to dicts
X_train = {'left': X_train.question1, 'right': X_train.question2}
X_validation = {'left': X_validation.question1, 'right': X_validation.question2}
X_test = {'left': test_df.question1, 'right': test_df.question2}
# Convert labels to their numpy representations
Y_train = Y_train.values
Y_validation = Y_validation.values
# Zero padding
for dataset, side in itertools.product([X_train, X_validation], ['left', 'right']):
    dataset[side] = pad_sequences(dataset[side], maxlen=max_seq_length)
 
Y_net_train = {'label' : Y_train}
Y_net_validation = {'label' : Y_validation}

Благодаря приведенным выше запускам у вас теперь есть доступ к наборам данных для обучения, тестирования и проверки, которые готовы к использованию в нашей архитектуре. — X_train, Y_net_train, X_test, X_validation, Y_net_validation

Обучение и проверка

Сиамская архитектура, с которой мы работаем, построена с использованием MxNet следующим образом

Примечание: два встраивания имеют одинаковые веса. Расстояние вычисляется при прямом проходе…

class Siamese(gluon.Block):
    def __init__(self, input_dim, embedding_dim, **kwargs):
        super(Siamese, self).__init__(**kwargs)
        
            #self.nn = gluon.nn.HybridSequential()
            
        self.embedding = nn.Embedding(input_dim, embedding_dim)
        
        self.encoder = gluon.rnn.LSTM(50,
                                bidirectional=True, input_size=embedding_dim)
            #self.nn.add(gluon.rnn.LSTM(10, bidirectional=True))
        self.dropout = gluon.nn.Dropout(0.3)
        self.dense = gluon.nn.Dense(32, activation="relu") 
     
    def forward(self,input0, input1):
       
        out0emb = self.embedding(input0)
        out0 = self.encoder(out0emb)
        
        out1emb = self.embedding(input1)
        out1 = self.encoder(out1emb)
        
        out0 = self.dense(self.dropout(out0))
        out1 = self.dense(self.dropout(out1))
        
        batchsize = out1.shape[0]
     
        xx = out0.reshape(batchsize, -1)
        yy = out1.reshape(batchsize, -1)
        manhattan_dis = F.exp(-F.sum(F.abs(xx - yy), axis=1, keepdims = True) + 0.0001
        return manhattan_dis

На данный момент в коде архитектуры нет ничего, что указывало бы на предпочтения графического процессора для обучения/оценки. Но когда мы определим модель и инициализируем сеть, нам нужно будет указать контекст, который указывает предпочтение ЦП или ГП.

Примечание. Сама архитектура очень проста, однако задача состоит в том, чтобы распараллелить обучение на нескольких графических процессорах. Сам этот процесс не очень хорошо задокументирован для MxNet и требует корректурного прочтения людьми из команды Алекса Смола (Шенг и Лю). В приведенном ниже коде подробно описаны усилия по распараллеливанию.

#initialize the network
net = Siamese(input_dim, embedding_dim)
ctx = d2l.try_all_gpus()
#check if you see all your 8 gpus if you have a p2x.8large instance
print(ctx)
#initialize the network using the context of GPU
net.initialize(init=init.Normal(sigma=0.01), ctx=ctx)

Вложения словаря слов должны быть доступны для всех графических процессоров. В приведенном ниже коде показано, как установить контекст словаря встраивания и сделать его доступным для модели.

gpuembeddings = (nd.array(embeddings)).as_in_context(mx.gpu())
#adding pretrained embeddings 
net.embedding.weight.set_data(gpuembeddings)
net.embedding.collect_params().setattr('grad_req', 'null')

Функция потерь, которая хорошо справляется с этой задачей, называется L2Loss. Кроме того, я выбрал здесь градиент «adadelta». Можно экспериментировать с другими типами потерь и градиентов.

trainer = gluon.Trainer(net.collect_params(), 'adadelta', {'clip_gradient': 1.25})
loss = gluon.loss.L2Loss()

Процесс обучения:

Данные обучения, отправляемые в сеть, должны быть распределены по всем доступным графическим процессорам. Это достигается с помощью функции split_and_load глюона.

Убыток также может накапливаться с помощью функции d2l Accumulator. Однако на данный момент я просто суммирую потери по мере получения доступа к ресурсу.

def train_model(dataiter, epoch):
   
    train_loss = 0
    total_size = 0
   
    for i, batch in enumerate(dataiter):
        
        data_list1 = gluon.utils.split_and_load(batch.data[0], ctx, even_split=True)
        data_list2 = gluon.utils.split_and_load(batch.data[1], ctx, even_split=True)
        label_list = gluon.utils.split_and_load(batch.label[0], ctx, even_split=True)
            
        with autograd.record(): # Start recording the derivatives
            
            losses = [loss(net(X1, X2), Y) for X1, X2, Y in zip(data_list1, data_list2, label_list)] 
            
        for l in losses:
            l.backward()
                
        trainer.step(batch.data[0].shape[0])
        total_size += batch.data[0].shape[0]
        train_loss += sum([l.sum().asscalar() for l in losses])
       
      
    nd.waitall()
    
    return train_loss/total_size

Для каждой эпохи мы проверяем обученную сеть по проверочному набору.

def validate_model(valdataiter):
    test_loss = 0.
    total_size = 0
   
    for batch in valdataiter:
        # Do forward pass on a batch of validation data
         
        data_list1 = gluon.utils.split_and_load(batch.data[0], ctx, even_split=False)
        data_list2 = gluon.utils.split_and_load(batch.data[1], ctx, even_split=False)
        labels = gluon.utils.split_and_load(batch.label[0], ctx, even_split=False)
            
        pys = [loss(net(X1, X2), Y) for X1, X2, Y in zip(data_list1, data_list2, labels)] 
        test_loss += sum([l.sum().asscalar() for l in pys])
       
        total_size += batch.data[0].shape[0]
      
    return test_loss/total_size

Собрав все это вместе, процесс обучения и проверки выглядит так:

training_loss = []
validation_loss = []
BATCH_SIZE = 1000
LEARNING_R = 0.001
EPOCHS = 10
THRESHOLD = 0.5
dataiter = mx.io.NDArrayIter(X_train, Y_net_train, BATCH_SIZE, True, last_batch_handle='discard')
valdataiter = mx.io.NDArrayIter(X_validation, Y_net_validation, BATCH_SIZE, True, last_batch_handle='discard')
animator = d2l.Animator('epoch', legend=['train loss','validation loss'], xlim=[1, EPOCHS])     
accuracy_lst = []
timer = d2l.Timer()
for epoch in range(EPOCHS):
    timer.start()
    dataiter.reset()
    valdataiter.reset()
  
    train_loss = train_model(dataiter, epoch)
    timer.stop() 
    
    animator.add(epoch+1, (train_loss, validate_model(valdataiter)) )
print('train loss: %.2f, %.1f sec/epoch on %s' % (
        animator.Y[0][-1], timer.avg(), ctx))

График потери обучения и потери проверки

Кривая ROC по набору проверки

Статистика:
данные о тренировках:

204544 : are not duplicates
119746 : are duplicates

данные проверки:

50483 : are not duplicates
29517 : are duplicates

Приложение

Модель была запущена на большом инстансе EC2 p2.8X с 8 инстансами GPU.