Пользователи iFunny ежедневно загружают в приложение около 100 000 единиц контента, среди которого не только мемы, но и расизм, насилие, порнография и другие недопустимые вещи. 

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

Как мы это в итоге сделали — расскажу под катом на наглядном примере. Статья рассчитана на тех, кто знаком с Python (при этом необязательно разбираться в Data Science и Machine Learning).

Классификация без разметки

Задача: сделать классификацию объектов.

Дано: множество данных без разметки и каких-либо подробностей.

Решение:

Для начала загрузим данные и проведём их первичный анализ: 

from sklearn.datasets import load_digits
 
dataset = load_digits()
dataset['data'].shape

У нас в наличии датасет размера (1797, 64). Это сравнительно небольшой набор данных — он меньше 2000, но и этого может быть достаточно, если выборка  репрезентативная (отражает особенности всего исследуемого множества). При этом у каждого объекта 64 признака — если они все бинарные (принимают значение 0 и 1), то нам потребуется 2^64 примеров, чтобы покрыть все возможные варианты. Для признаков, которые принимают 3 и больше значений, размер всеобъемлющей выборки будет ещё больше. На практике лишь небольшое число признаков несёт основную информацию об объекте и принимает гораздо меньше значений из допустимого множества. 

Выведем несколько строк из набора на экран:

dataset.data[10:15]

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

Перед работой с любыми данными стоит смотреть на статистику по разным признакам (столбцам). Взглянем на несколько случайных столбцов — возьмем с 30-го по 35-й и выведем статистику с помощью библиотеки pandas. 

Метод describe позволяет посмотреть набор самых часто используемых статистик из таблицы ниже. Значения признаков группируются около нуля, на что указывают их средний показатель. Также есть признаки с нулевым значением у всех объектов выборки, значит они неинформативны и их можно не использовать при дальнейшем анализе.

dataset_df = pd.DataFrame(dataset.data[:, 30:35])
dataset_df.describe()

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

import seaborn as sns
sns.pairplot(dataset_df);

В нашем случае видно лишь то, что все признаки принимают целочисленные значения. Отсутствие парных корреляций не исключает наличия зависимости между большим числом признаков одновременно. Но увидеть такие особенности данных невозможно — у нас 64-мерное признаковое пространство. Даже если в нём есть области, где объекты группируются, то обнаружить это каким-либо графическим методом будет крайне сложно (а может и совсем невозможно). 

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

Понижение размерности

Для начала избавимся от константных признаков. Выше мы отметили наличие признаков со значением 0 у всех объектов, поэтому спокойно их удаляем во всей выборке. Наша цель — разделить объекты, а значит, основная информация заключается в отличии их друг от друга. 

Есть множество способов уменьшить размерность признакового пространства, сохранив его информативность. Для статьи возьмём алгоритм UMap, так как уже используем его в своих задачах. Одно из его преимуществ перед другими алгоритмами нелинейного снижения размерности — возможность обучать на одном наборе данных, а затем использовать его в дальнейшем на новых данных, применяя одно и то же преобразование.

Используем уже готовую библиотеку. Здесь самый важный параметр — количество компонент, которое нужно получить на выходе (до какой размерности сжать текущее пространство признаков). Выбираем два, потому что 2D-плоскость можно наглядно отобразить на рисунке:

import umap
reducer = umap.UMAP(n_components=2, random_state=47)

Делаем обучение командой fit. Данных не так много, поэтому обучаем на всём наборе, но, как было сказано ранее, он может быть меньше итогового:

reducer.fit(dataset.data)

Далее преобразуем все данные:

embeddings = reducer.transform(dataset.data)

И на выходе получаем уменьшенную размерность — количество образцов то же самое, но признаков всего два: (1797, 2).

Коротко о том, как это работает: UMap строит взвешенный граф, соединяя ребрами ближайших соседей в n-мерном пространстве, затем создает другой граф в низкоразмерном пространстве и приближает его к исходному так, чтобы сохранить относительное положение объектов. То есть близкие объекты оставляет ближе, дальние — дальше, но уже в уменьшенной размерности.

Построим график полученных 2D-векторов :

plt.scatter(embeddings[:, 0], embeddings[:, 1], s=5)

На графике видно 10 больших групп точек и ещё несколько поменьше. Проведём кластеризацию — разобьём на области, основываясь на каком-либо параметре или правиле. 

Кластеризация

Воспользуемся алгоритмом k-средних (KMeans), который основывается на минимизации суммарного квадратичного отклонения точек кластеров от центров этих кластеров.

Задаем поиск 10 кластеров (на предыдущем графике видно 10), делаем обучение и предсказание итоговых классов:

clustering = KMeans(n_clusters=10)
classes = clustering.fit_predict(embeddings)

Раскрасим картинку с кластерами. Алгоритм очень хорошо их разделил:

plt.scatter(embeddings[:, 0], embeddings[:, 1], c=classes, cmap='Spectral', s=5)
plt.colorbar(boundaries=np.arange(11)-0.5).set_ticks(np.arange(10))

Полученные порядковые номера кластеров можно считать классами неразмеченной выборки. Для классификации новых данных нужно последовательно применить к ним уже обученные алгоритмы UMap и KMeans и получить номер кластера для этих объектов.

А теперь открою небольшую тайну — это были не просто данные.

Исходные данные

В тренировочном примере данные являются картинками 8×8 пикселей с рукописными числами. Если значения интенсивности всех пикселей слева-направо и сверху-вниз выложить в одну строку, то получится вектор длины 64 — именно тот, с которым работали до этого. Интенсивность в пикселе записана в формате uint8 и принимает только целочисленные значения от 0 до 255, а значит наши наблюдения в самом начале были верны. 

Всего в датасете представлены цифры от 0 до 9, то есть как раз 10 классов (столько же кластеров нам удалось выделить):

Теперь у нас есть истинный класс и известно, какой строке соответствует какая метка. Если изобразить истинное распределение классов в пространстве меньшей размерности с помощью найденного преобразования, то получится следующее:

Точность классификации

На картинке выше видно, что в большинстве случаев отличаются только цвета, которые отвечают за номер кластера. Это связано с тем, что метод k-средних расставлял метки случайным образом, не вкладывая в 0-й класс смысл наличия нулей в его изображениях. Если поменять нумерацию, то станет видно, какое число примеров было выделено правильно. 

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

Именно в такой ситуации мы сейчас находимся, поэтому стоит обратиться к матрице ошибок. Для ее построения используем библиотеку pycm:

from matplotlib.pyplot import cm
from pycm import ConfusionMatrix

y_true = dataset.target
conf_matrix = ConfusionMatrix(actual_vector=y_true, predict_vector=y_pred)
conf_matrix.plot(cmap=cm.Greens, number_label=True);

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

  • По горизонтали — классы, предсказанные нашим методом. 

  • По вертикали — истинные классы.

  • В клетках пересечения — количество объектов, удовлетворяющих двум условиям. 

27 образцов из истинного класса единиц почему-то определились как шестерки. Разберёмся, почему так вышло и посмотрим на картинки из датасета.

Единицы, классифицированные как шестерки
Единицы, классифицированные как шестерки

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

А реальные шестёрки и правда порой похожи на единицы (особенно первая в третьем ряду и третья в первом), поэтому тут вопросы не к нашей модели, а к тому, кто так пишет: 

Шестерки, классифицированные верно
Шестерки, классифицированные верно

Вместо заключения

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

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

  • approved — картинки идут в раздел приложения collective;

  • not suitable — не попадают в общую ленту, но остаются в ленте пользователя (девушки в купальниках и мужчины в плавках, селфи и всё, что не является мемами);

  • risked — такой контент получает бан и перестает быть доступным для всех пользователей iFunny (расизм, порнография, расчленёнка и всё, что попадает под определение «противоправный контент»).

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

Комментарии (8)


  1. Alexey2005
    07.09.2021 15:39
    +1

    Проблема всех этих нейронных классификаторов в том, что у них крайне высок процент ошибок по сравнению с живым модератором. «Из коробки» большинство таких сеток даёт что-то около 80% точности.
    Если как следует постараться, её можно поднять до 90%, а дальше каждый дополнительный процент придётся вырывать всё большими усилиями. При этом даже 99% точность означает, что каждую сотую картинку сетка ошибочно забанит, а каждую сотую недопустимую — пропустит. Когда у сервиса миллионы пользователей, такой недо-классификатор будет дико бесить пользователей, потому что с его ошибками будет постоянно сталкиваться каждый пользователь системы.
    Совсем «замечательно», когда для экономии ресурсов сервисы ещё и не позволяют оспорить бан.


    1. MrNightSky Автор
      07.09.2021 16:53
      +5

      В статье описаны методы без использования нейросетей, но в целом вы правы — алгоритмы не идеальны и допускают ошибки.

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

      Да, 100% точности здесь достигнуть невозможно, но и нельзя полагаться только на ручную разметку. У автоматических алгоритмов есть свои плюсы — иногда модель делает предсказания точнее, чем человек (она не устает и не отвлекается во время работы). Другой важный момент — алгоритмы снижают нагрузку на психику наших модераторов, которым приходится отсматривать порой ужасные вещи.

      Оставшийся 1% ошибок скорее всего будет нетривиальным кейсом не только для машины, но и для модераторов, и для пользователей. А из-за субъективности восприятия этот 1% может быть недопустим только для 1% пользователей, поэтому такая ошибка не будет настолько критична :)


      1. VladPavlushin
        09.09.2021 14:19

        а как оценивается / оцифровывается "недостаточная уверенность" ?

        с теми же 6-ками и 1-ми

        несмотря на то, что это вопрос к тем кто написал, на каком сновании первая шестерка в третьем ряду должна попасть к модератору на ручную разметку.?

        хотя это наверно тема отдельной статьи...

        и интересно, почему ни наоборот, ни одна из 1 не определилась как 6ка


        1. MrNightSky Автор
          09.09.2021 14:19
          +3

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

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

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

          и интересно, почему ни наоборот, ни одна из 1 не определилась как 6ка
          Полагаю, здесь опечатка, т.к. в статье показано, что некоторые единицы определены как шестерки, но как раз нет ни одной шестерки, определенной как единица. Чтобы это понять, нужно посмотреть на матрицу ошибок, где на пересечении 1 (верный класс) и 6 (предсказанный моделью) стоит число 27 (оранжевый), а на пересечении 6 (верный класс) и 1 (предсказанный) — 0 (красный).

          Матрица ошибок
          "

          Почему не было шестёрок, предсказанных как единицы — сложно сказать, но предположу, что это может быть связано с отсутствием наклона влево у остальных единиц (это видно на рисунке ниже, где большинство единиц написаны без наклона), который мы видим у неверно классифицированных примеров.

          Рисунок


          1. VladPavlushin
            10.09.2021 09:25

            благодарю за разъяснения.

            с 6 / 1 напутал с формулировкой, Вы верно поняли.

            наклон, да видимо он, что, вероятно, подтверждает двадцать 9ок (имеющих в своей основе левый наклон), определенных, как 1

            а за счет чего, по вашему, получился такой большой процент ошибок в данном случае- 5% (явных ошибок 88 из 1797) ? можно ли его снизить?


            1. MrNightSky Автор
              10.09.2021 14:42
              +2

              47 из 89 ошибок (по моей confusion matrix их выходит 89) мы с вами уже попытались объяснить. Ещё могу предположить, что у девяток, предсказанных как семерки, такая же проблема с наклоном, но это опять же только догадки. Нужно смотреть более детально, чтобы сказать наверняка. У нас есть гипотеза возникновения примерно 65% ошибок, если она верна, то можно попробовать аугментацию — в данном случае напрашивается использовать поворот на небольшой угол (30-45 градусов).

              Так мы увеличим количество чисел с наклоном и покажем модели, что это допустимое и достаточно частое изменение. Но для алгоритма из статьи такой подход может не сработать (из-за его простоты), поэтому для улучшения качества потребуется что-то сложнее, скажем, перцептрон или небольшая нейросеть на свёртках, которые смогут выделять более сложные паттерны. Если все получится, выйдет порядка 97-98% точности. Оставшиеся 2% скорее всего решить не выйдет, и даже если получится достигнуть 100% на этом наборе данных, то, отвлекаясь на столь небольшое число выбросов, мы рискуем потерять общность и получить больше проблем на новых данных (это называется переобучением).


  1. Kwent
    07.09.2021 18:27

    Эти паттерны выделяет нейросеть, предобученная на большом наборе данных.

    А можно пару слов (или еще одну статью) про это? Обычный ImageNet или ваши датасеты? Предобучена на классификации или что-то без учителя?


    1. MrNightSky Автор
      08.09.2021 10:35

      Мы используем одну из общедоступных сетей классификации изображений.