Сопоставление изображений с Shopee

Текст и изображения — это новые числа.

Аюш Малани, Джексон Хассел, Сунхо Пак, Марио Гонсалес и Анант Гупта

«Гарантия лучшей цены» — термин, с которым онлайн-покупатели сталкиваются очень часто, но редко останавливаются, чтобы подумать о том, как компании обеспечивают эту рискованную гарантию. а затем проверьте ценник этого продукта. Почти каждая крупная компания, занимающаяся электронной коммерцией, использует Гарантию лучшей цены, поскольку доказано, что это один из наиболее эффективных способов привлечения целевой аудитории.

Здесь мы проходим через этот процесс, сначала исследуя набор данных, состоящий как из заголовков изображений, так и из изображений, затем вычисляя сходство заголовков с помощью TF-IDF и вложений слов, а затем используя текст и изображения для их классификации в определенные группы меток с использованием алгоритмов EfficientNet B0 и Б6.

Введение

Shopee — это популярная в Юго-Восточной Азии платформа интернет-магазина, похожая на Amazon. Их проблема заключается в сопоставлении продуктов — они хотят идентифицировать два продукта как идентичные и сгруппировать их в одном списке, чтобы покупатель не попадал в поток идентичных продуктов каждый раз, когда он что-то ищет.

Этот набор данных взят из конкурса kaggle здесь: https://www.kaggle.com/c/shopee-product-matching/data. Он включает 34 000 продуктов, каждый из которых имеет связанное изображение, заголовок и группу ярлыков. Каждая группа этикеток представляет собой уникальный идентификатор, объединяющий от 2 до 50 продуктов вместе и идентифицирующий их как идентичные. Всего имеется 11 000 меток, и цель состоит в том, чтобы предсказать группу меток с учетом изображения и заголовка.

Один из способов думать об этом как о базовой проблеме мультиклассовой классификации, но это намного сложнее, чем кажется. У нас есть 11 000 классов и в среднем 3 точки данных на класс. Для таких моделей, как Наивный Байес, просто недостаточно информации для оценки апостериорных вероятностей каждого класса из такого ограниченного набора данных. У вас может быть ограниченный успех с нейронной сетью, которая имеет выходной массив из 11 000 элементов, и взять из этого hardmax. Но самое простое решение — K-ближайшие соседи.

K-ближайшие соседи — это довольно простой алгоритм — при прогнозировании класса новой точки данных он просто просматривает k ближайших элементов (где k выбирается вами, но обычно составляет от 1 до 10) и выбирает класс, который больше всего подходит. общего в этих элементах. Он часто используется из-за своей простоты и объяснимости, хотя часто уступает более сложным моделям. Но он идеально подходит для нашего набора данных. У нас есть сильно сгруппированные данные, и мы пытаемся найти совпадения между почти идентичными точками данных — именно в этом K-ближайшие соседи преуспевают.

Ниже мы расскажем вам, как мы исследовали данные, векторизовали столбцы текста и изображения и, в конечном итоге, смоделировали данные.

Исследование данных

Чтобы направлять процесс прогнозного моделирования для любой проблемы, всегда рекомендуется анализировать и исследовать данные, чтобы найти закономерности, которые можно использовать в дальнейшем в анализе. Как и в случае со структурированными данными, исследовательский анализ данных играет очень важную роль в решении таких задач, как классификация текста или изображений с использованием неструктурированных данных. С более чем 32 000 изображений мы рассмотрели распространение и тенденции в предоставленных данных изображений.

В предоставленном нам наборе данных, помимо изображений, у нас были такие функции, как posting_id, image_phash, title и label_group. Мы проанализировали каждую из этих функций шаг за шагом. С предварительными проверками подсчета, показанными ниже, мы можем сделать вывод, что только posting_id является уникальной записью в данных, а все остальные функции имеют те или иные дубликаты. Более того, у нас есть примерно 11 000 групп меток, и они будут определять наши классы, поскольку каждая группа меток является зонтиком для нескольких изображений.

for col in train.columns:
    print(col + ":" + colored(str(len(train[col].unique())), 'red'))

Далее мы исследовали частоты заголовков. Почти у каждого изображения есть связанный с ним заголовок, и после удаления стоп-слов с помощью библиотеки wordcloud мы визуализировали эти частоты с помощью облака слов.

stopwords = set(STOPWORDS)
wordcloud = WordCloud(width = 800,
height = 800,
background_color ='white',
min_font_size = 10,
stopwords = stopwords,).generate(' '.join(train['title']))
# plot the WordCloud image
plt.figure(figsize = (8, 8), facecolor = None)
plt.imshow(wordcloud)
plt.axis("off")
plt.tight_layout(pad = 0)
plt.show()

Однако результаты были неубедительны, так как большинство продуктов имеют названия на региональных языках Южной Азии, что затрудняет их понимание.

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

top10_names = train['label_group'].value_counts().index.tolist()[:15]
top10_values = train['label_group'].value_counts().tolist()[:15]
top_10_df = pd.DataFrame(zip(top10_names,top10_values),columns=['Label Group','Image Count'])
plt.figure(figsize=(14, 7))
sns.barplot(x=top_10_df['Label Group'],
y=top_10_df['Image Count'],
order = top_10_df.sort_values('Image Count',ascending=False)['Label Group'])
plt.xticks(rotation=45)
plt.xlabel("Label Group")
plt.ylabel("Image Count")
plt.title("Top-15 Label Groups by Image Count")
plt.show()

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

Более того, мы видим очень длинное хвостовое распределение, где примерно 80% групп ярлыков имеют под собой всего 2–3 изображения. При существующей проблеме очень большого количества классов это также налагает задачу решения проблемы дисбаланса классов.

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

Ниже приведен код, используемый для создания функции, которая помогает нам визуализировать изображения с использованием путей к изображениям. Получив изображения, мы затем попытались сегментировать объекты на изображениях, так как позже это могло помочь нам идентифицировать продукты и вычислить сходство. Сегментация изображения — это, по сути, процесс разделения цифрового изображения на несколько сегментов для упрощения и/или изменения представления изображения во что-то более осмысленное и более простое для анализа. Мы видим, что наши изображения имеют много контуров. , смесь разных цветов и очень шумно. Таким образом, чтобы разделить изображения на основе плотности пикселей, мы устанавливаем порог для разделения черного и белого среди изображений. Этот алгоритм Контролируемого порогового значения дал нам предварительное представление о том, как пиксели распределяются на изображениях.

def display_multiple_img(images_paths, rows, cols):
"""
Function to Display Images from Dataset.
parameters: images_path(string) - Paths of Images to be displayed
rows(int) - No. of Rows in Output
cols(int) - No. of Columns in Output
"""
figure, ax = plt.subplots(nrows=rows,ncols=cols,figsize=(16,8) )
for ind,image_path in enumerate(images_paths):
image=cv2.imread(image_path)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
try:
ax.ravel()[ind].imshow(image)
ax.ravel()[ind].set_axis_off()
except:
continue;
plt.tight_layout()
plt.show()

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

# get image dimensions
def get_dims(file):
img = cv2.imread(file)
h,w = img.shape[:2]
return h,w
# parallelize
filelist = train_images_path
dimsbag = bag.from_sequence(filelist).map(get_dims)
with diagnostics.ProgressBar():
dims = dimsbag.compute()
dim_df = pd.DataFrame(dims, columns=['height', 'width'])
sizes = dim_df.groupby(['height', 'width']).size().reset_index().rename(columns={0:'count'})
sizes.hvplot.scatter(x='height', y='width', size='count', xlim=(0,1200), ylim=(0,1200), grid=True, xticks=2,
yticks=2, height=500, width=600).options(scaling_factor=0.1, line_alpha=1, fill_alpha=0)

Этот визуальный элемент предоставляет подробную информацию о разбросе размеров на двумерном графике. Размер кругов представляет собой количество изображений, имеющих этот конкретный размер. Как показано ниже, изображения 6034 имеют размер 1024 * 1024 и так далее.

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

Векторизация текста

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

Один из самых простых способов сделать это — рассчитать баллы TF IDF. По сути, они представляют собой меру того, насколько часто встречается каждое слово в каждом заголовке, взвешенное по количеству других заголовков, содержащих это слово. Это способ определить, какие слова отличают каждый документ от остальных. Это отличный способ сравнить заголовки без необходимости понимать, что на самом деле говорит заголовок, поскольку он заботится только о том, насколько часто встречается каждое слово, а не о том, что оно означает. Кроме того, после того как вы должным образом предварительно обработаете и разметите свой текст, очень легко рассчитать баллы TF IDF, например.

from sklearn.feature_extraction.text import TfidfTransformer
tfidf_transformer = TfidfTransformer()
X_tfidf = tfidf_transformer.fit_transform(X_counts)
X_tfidf.shape

У TF IDF есть только одна проблема — набор данных, который он генерирует, огромен. Для каждого уникального слова, которое появляется в каждом заголовке, есть столбец, даже если это слово появилось только один раз. Это означает, что большая часть матрицы — нули, но вам все равно нужно ее представить. TF IDF хорош для сравнения заголовков, но нам нужно посмотреть, сможет ли наша модель справиться с таким количеством столбцов и не заглушит ли такое количество столбцов кодировку изображений.

Другой способ векторизации текста — встраивание слов. Встраивание в слова — один из самых популярных сегодня способов векторизации текстовых данных. Сгенерированные с помощью нейронных сетей, обученных на огромных объемах текстовых данных, они представляют собой одномерные массивы длиной в несколько сотен элементов, и никто не может сказать вам, что означает каждый элемент в этом массиве. Но в целом каждый массив представляет собой одно слово и обладает некоторыми интересными свойствами. Знаменитый пример: если вы возьмете массив для «короля», вычтете из него массив для «мужчины» и добавите массив для «женщины», он будет почти идентичен массиву для «королевы». То же самое касается столиц разных стран или даже известных политических деятелей.

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

Генерация встраивания слов на самом деле чрезвычайно сложна, но, как и большинство вещей в науке о данных, ее можно выполнить всего за несколько строк кода, как показано ниже. Это для создания вложений слов с помощью BERT.

text_embeddings = np.zeros((len(train_df), 768))
i = 0
for title in train_df['title']:
tokens = tokenizer.tokenize(title)
segments_ids = [1] * len(tokens)
indexed_tokens = tokenizer.convert_tokens_to_ids(tokens)
tokens_tensor = torch.tensor([indexed_tokens])
segments_tensors = torch.tensor([segments_ids])
outputs = model(tokens_tensor, segments_tensors)
hidden_states = outputs[2]
token_vecs = hidden_states[-2][0]
sentence_embedding = torch.mean(token_vecs, dim=0)
text_embeddings[i] = sentence_embedding.detach().numpy()
i += 1

Приведенный выше код был адаптирован из учебника здесь https://mccormickml.com/2019/05/14/BERT-word-embeddings-tutorial/, мы настоятельно рекомендуем его, если вам нужно руководство по созданию встраивания текста.

Существует множество способов создания вложений слов, и BERT может оказаться не лучшим для нашего набора данных. На всякий случай мы также создали вложения слов с помощью Word2Vec, который работает так же, как BERT, но использует другую предварительно обученную нейронную сеть. Мы сделали это, используя приведенный ниже код:

def word2vec_model():
w2v_model = Word2Vec(min_count=3,
window=3,
sample=1e-5,
alpha=0.03,
sg=1,
min_alpha=0.0007,
negative=20)
w2v_model.build_vocab(processed_docs)
w2v_model.train(processed_docs, total_examples=w2v_model.corpus_count, epochs=300, report_delay=1)
return w2v_model
w2v_model = word2vec_model()
w2v_model.save('word2vec_model')
w2v_model.wv.vectors.shape

Векторизация изображения

Люди говорят, что изображение стоит тысячи слов, и они правы — изображения невероятно насыщены информацией и могут многое рассказать о предмете. Хитрость заключается в извлечении этой информации. Человеческий мозг очень хорошо изолирует объекты и действия на изображениях, но компьютеры все еще плохо с этим справляются. Изображения для компьютеров — это всего лишь трехмерные массивы; каждый пиксель представляет собой комбинацию трех значений красного, синего и зеленого. Вы можете попытаться ввести этот необработанный 3D-массив в алгоритм K-ближайших соседей, но далеко вы не продвинетесь. Нам нужно обработать это изображение и извлечь из него важные числовые характеристики, которые сможет понять модель.

Одним из способов сделать это является встраивание изображений. Встраивание изображений во многом похоже на встраивание текста: они представляют собой сгенерированный компьютером массив чисел (обычно длиной в несколько сотен элементов), которые сжимают информацию в изображении до приемлемого размера. Обычно они генерируются с помощью сверточных нейронных сетей, которые являются одной из немногих моделей, которые могут принимать необработанные изображения в качестве входных данных. Теперь, полное объяснение того, как работают CNN, уведет нас слишком далеко от темы, но нужно вынести одну вещь: нейронные сети чрезвычайно хороши в извлечении признаков. Благодаря тому, как они рассчитывают веса, нейронные сети могут выбирать, какие функции важны, комбинировать функции всевозможными способами для создания новых, лучших функций и так далее. Мы можем воспользоваться этим.

Нейронные сети имеют последний слой, который сжимает их веса в один результат — число для регрессии. Мы можем обучить сеть на наборе данных изображений, игнорировать выходной слой сжатия и посмотреть на выходные данные предпоследнего слоя в сети. Этот вывод является нашим встраиванием изображения. Каждый раз, когда мы хотим сгенерировать встраивание изображения для нового изображения, мы запускаем его через эту предварительно обученную CNN, останавливаем его прямо перед тем, как он попадет на последний слой, и извлекаем выходные данные для передачи в другие модели. Мы не знаем, что означает этот вывод — что он представляет для изображения или что означает каждый элемент в массиве. Но мы знаем, что CNN считала это самой важной, отличающей информацию от этого изображения, и это делает ее ценной для других наших моделей.

Мы использовали два разных автоэнкодера: EfficientNetB0 и EfficientNetB6. Единственная разница заключается в форме изображений, которые они принимают в качестве входных данных — B0 принимает изображения 224 x 224, а B6 — 526 x 526 изображений. Мы не были уверены, что лучше подходит для нашего набора данных, поэтому запустили оба.

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

from os import listdir
from PIL import Image
# load all images in a directory
images = np.ndarray(shape=(32412, 224, 224, 3), dtype='int16')
file_names = []
i = 0
for filename in listdir('train_images'):
file_names.append(filename)
images[i] = np.array(Image.open('train_images/' + filename).resize((224,224)))
i += 1

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

from tensorflow.keras.applications import EfficientNetB0
model = EfficientNetB0(include_top=False, weights='imagenet', pooling='avg', input_shape=None)
emb = model.predict(images)

Моделирование

Мы рассмотрели две перестановки K-ближайших соседей: одну модель, использующую евклидово расстояние, и одну модель, использующую косинусное расстояние. Евклидово расстояние является стандартом для KNN, но косинусное расстояние становится все более популярным при работе с текстовыми данными, потому что оно очень хорошо обрабатывает повторяющиеся слова. Наши данные — это только текстовые данные наполовину, но стоит протестировать обе модели, чтобы увидеть, какая из них работает лучше. Мы также устанавливаем K равным 1 — проблема заключается в сопоставлении продуктов, и нас интересует только наиболее похожий продукт, поэтому K = 1 имеет наибольший смысл.

Мы изучили 3 способа векторизации наших текстовых данных (два разных вида встраивания слов и оценки TF IDF) и 2 способа векторизации наших данных изображения (EfficientNetB0 и EfficientNetB6), но мы не уверены, какой из них лучше. Поэтому мы проверили каждую комбинацию функций, чтобы увидеть, какая из них работает лучше всего. Модели были проверены с использованием перекрестной проверки KFold, чтобы избежать переобучения или недообучения, и оценены с использованием показателя F1 в качестве метрики (которая варьируется от 0 до 1, где 1 является лучшим).

Результаты и заключение

Баллы F1 для каждого набора данных и модели.

Вот код, который мы использовали для запуска нашей модели:

def run_model(X, metric='euclidean'):
y = labels.values
n_splits = 5
kfold = StratifiedKFold(n_splits=n_splits)
result = 0
for train_index, test_index in kfold.split(X, y):
X_train, X_test = X[train_index,:], X[test_index,:]
y_train, y_test = y[train_index], y[test_index]
model = KNeighborsClassifier(n_neighbors=1, metric=metric).fit(X_train, y_train)
pred = model.predict(X_test)
result += f1_score(y_true=y_test, y_pred=pred, average='micro')
return result/n_splits

Поначалу эти результаты не имеют особого смысла. Почему BERT показал себя намного хуже, чем Word2Vec, хотя он и новее, и более продвинутый? Почему TF IDF показал себя так плохо, хотя теоретически это должен быть отличный способ сравнить сходство между названиями? Почему EfficientNetB0 почти всегда превосходил EfficientNetB6, когда B6 имел больше данных (в виде изображений с более высоким разрешением) для работы?

Но это имеет смысл, если посмотреть, сколько функций есть в каждом из наших наборов данных. BERT генерирует 768 признаков. Word2Vec генерирует только 100. Идентификатор TF генерирует 24 951. EfficientNetB0 генерирует 1280, а EfficientNetB6 генерирует 2304, т. е. более чем на 1000 больше.

Это важно, потому что K-ближайших соседей — очень простой алгоритм. Он одинаково взвешивает каждую функцию, а это означает, что если вы добавите ему кучу менее полезных функций, он начнет работать значительно хуже. В данных EfficientNetB6 гораздо больше информации, но мы не можем использовать ее в нашей модели, потому что многие из дополнительных функций — это просто шум. Вот почему лучшим набором данных оказались Word2Vec и EfficientNetB0 — у них было наименьшее количество признаков и, следовательно, была самая высокая плотность информации на каждый признак, поэтому наша модель не запуталась. По сути, наличие слишком большого количества функций приводило к переоснащению нашей модели.

Это не было бы проблемой, если бы мы использовали другую модель. Многие модели имеют встроенную регуляризацию, которая позволяет им допускать бесполезные функции, но не KNN. Мы использовали KNN, потому что он очень хорошо соответствует структуре задачи — мы хотим определить наиболее похожий продукт, чтобы присвоить группу меток, что и делает KNN. Однако вполне возможно, что обучение более сложной модели (например, нейронной сети) на этих более сложных наборах данных фактически превзойдет KNN. У нас не было времени, чтобы попробовать это, но это то, что следует изучить в будущей работе в этой области.

Вывод здесь таков: не предполагайте, что вы знаете, как модель будет работать, прежде чем запускать ее — если у вас есть время, лучше запустить несколько моделей на нескольких наборах данных, чтобы убедиться, что вы выбираете лучшие инструменты для работы. Мы предполагали, что EfficientNetB6 будет работать лучше, чем EfficientNetB0, потому что вся информация в последнем содержится в первом, по крайней мере, хуже быть не может. Но очевидно, что это не так, и мы бы не узнали об этом, если бы не попробовали EfficientNetB0.

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

Репозиторий кода: https://github.com/JacksonHassell/ShopeeProductMatching

Рекомендации

Создавайте вложения текста с помощью BERT: https://mccormickml.com/2019/05/14/BERT-word-embeddings-tutorial/

Создавайте вложения изображений с помощью EfficientNet: https://keras.io/examples/vision/image_classification_efficientnet_fine_tuning/

Сегментация изображения под наблюдением Random Walker: https://ieeexplore.ieee.org/document/1704833

Сегментация обнаружения краев:
https://cnvrg.io/image-segmentation/