Как стать автором
Обновить

Как искусственные нейросети помогают в поиске любви: опыт использования для фильтрации анкет в дейтинг-приложении

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров3.4K
Девушка мечты ("представление" YandexART)
Девушка мечты ("представление" YandexART)

Заметили сколько новостей и статей начало выходить с упоминанием нейросетей и дейтинг приложений в одном тексте? Возможно научить нейросеть фильтровать анкеты в дейтинг сервисе? Помогает это? Я постараюсь ответить на эти и некоторые другие вопросы в своей статье. Расскажу, как я к этому пришёл и зачем вообще начал разбираться с этим вопросом. Каким образом я у себя реализовал такую систему. В дополнение затрону немного этическую сторону данного вопроса. Всем интересующимся добро пожаловать к чтению.

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

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


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


Сбор данных

Для начала, необходимо было придумать способ данные собрать, желательно не особо напрягаясь, ещё желательно их сразу размечать, или размечать автоматически. И тут я пришёл к решению сделать простое браузерное расширение. В начале, необходимо разобраться, каким образом это можно сделать (на самом деле, уже опыт есть, но вам расскажу).

Разработка расширения

Вообще, если упрощённо, то расширение является приложением, если точнее, веб-приложением. У расширения есть главная HTML страница, стили, если нужны, и скрипты. Ещё есть, так называемый, манифест, на данный момент (Июнь 2024 года), уже используется третья версия манифеста. Структура манифеста простая и лучше её смотреть в документации Google. Есть множество способов локации расширений, например, боковая панель или, просто, инжектируемый скрипт (скорее всего, вы именно этот вариант встречали чаще). Сразу оговорюсь, я пытался сделать с помощью боковой панели, для удобного управления расширением и просмотра ответов сервера, в итоге, у боковой панели имеются особенности, которые не позволяют нормально работать с CSP (она не имеет доступа к дочерним фреймам на странице), только если броадкастить сообщения для всех участников браузера, что не круто. Именно поэтому, перешёл к более пещерному варианту с обычным инжектом скрипта на страницу. Именно это позволяет заинжектить сразу и в дочерний фрейм, так как у фрейма родительского есть доступ.

Сразу после инжекта, скрипт ставит хуки на управляющие кнопки: лайк, дизлайк, пролистывание фотографий. С этим были особенности. Приложение написано на одном из фронтенд фреймворков (догадываюсь на каком, но не могу это доказать, поэтому не стану) и ни классов (с нормальными названиями), ни ID у элементов нет, по крайней мере, у управляющих кнопок. Я нашёл хитрость одну. Когда проводил исследование, обнаружил, что у многих элементов управления имеется атрибут "data-testid". От него и начал. Поставил хуки. Но, и тут проявилась интересная особенность, оказывается, иногда, эти элементы обновляет (то есть, вообще). Не знаю, защита это такая или нет, я больше склоняюсь, что это делает фреймворк, но всё же, решать надо. Пришлось поставить таймер ежесекундный, который будет проходится по элементам и выставлять хуки заново, если их нет. Для проверки, стоят или нет хендлеры, я решил использовать собственный атрибут "control".

function setManageElements()
{
  for (let element of document.getElementsByTagName('div')) {
    let attribute = element.getAttribute('data-testid');

    if(attribute == null || element.getAttribute('control') != null) continue;

    switch (attribute) {
      case 'like':
        element.addEventListener('click', () => sendData('liked'));
        element.setAttribute('control', 'extensionLiked');
        break;
      case 'dislike':
        element.addEventListener('click', () => sendData('disliked'));
        element.setAttribute('control', 'extensionDisliked');
        break;
      case 'next-story-switcher':
        element.addEventListener('click', nextStory);
        element.setAttribute('control', 'extensionStory');
        break;
      }
  }
}

setInterval(() => {
  setManageElements();
}, 1000);

Хм, осталось автоклик приделать на лайк и готово.

Зачем хуки, спросите? Всё просто, таким способом я решил сразу проставлять автоматически метки, какие профили мне понравились или не понравились. Для получения ссылки на фотографию, я уже нашёл элемент изображения с читаемым называнием класса "vkuiCustomScrollView__box" и, просто, получал source этого элемента при прокликивании анкет, заносил в массив и при оценке анкеты отсылал на сервер.

function nextStory() {
  let urlImg = document
    .getElementsByClassName('vkuiCustomScrollView__box')[0]
    .getElementsByTagName('img')[0].src;

  if(!photos.includes(urlImg))
    photos.push(urlImg)
}

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

function getData()
{
  let data = [];

  Array.from(document.getElementsByClassName('vkuiTypography')).slice(6)
    .forEach(element => {
      data.push(element.textContent);
    }
  );

  return data;
}

Процесс сбора данных:

Сервер

В нескольких словах, это сервис для обработки информации поступающей от расширения, развёртывается локально. Писал на FastAPI.

app = FastAPI()

app.add_middleware(
  CORSMiddleware,
  allow_origins=origins,
  allow_credentials=True,
  allow_methods=["*"],
  allow_headers=["*"],
)


@app.post('/save-data')
async def save_data(request: Request) -> Response:
  uuid4 = uuid.uuid4().hex
  request_body: dict = orjson.loads(await request.body())
  
  info = request_body.get('info')
  photos = request_body.get('photos')
  event_type = request_body.get('event_type')
  
  with open(f'{event_type}/{uuid4}.json', 'w', encoding='utf-8') as _:
    _.write(orjson.dumps({
      'info': info,
      'photos': photos
    }).decode())

  return Response(f'{event_type}, {len(photos)} photos saved, {uuid4}')

@app.get('/check')
def check() -> Response:
  return Response(status_code=200)

Метод save_data, как раз и занимается приёмом и разметкой информации. Каждой анкете присваивается uuid4 и сохраняется анкета сразу под определённой меткой (liked, disliked). Таким образом я собрал достаточное количество данных, и начал заниматься обработкой.

Обработка данных

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

def find_string(pattern: str, str: str) -> str | None:
    _ = re.match(pattern, str)
    return _.string if _ else None


profiles = []

for label in ['liked', 'disliked']:
    index_photo = 0

    for filename in tqdm.notebook.tqdm(os.listdir(f'../server/{label}')):
        with open(f'../server/{label}/{filename}', 'r', encoding='utf-8') as f:
            data = orjson.loads(f.read())

            data_profile = template_profile.copy()
            for index, datafield in enumerate(data['info']):
                if index < 15:
                    if not data_profile['_Age'] or not data_profile['_Name']:
                        age_name = find_string(r'^\S*, \d{2}$', datafield)

                        if age_name:
                            age_name = age_name.split(', ')

                            data_profile['_Age'] = age_name[1]
                            data_profile['_Name'] = age_name[0]

                    if len(datafield) > 50:
                      data_profile['_Description'] = datafield

                    if not data_profile['Height']:
                        height = find_string(r'^\d{3} см$', datafield)

                        if height:
                            data_profile['Height'] = height.split(' ')[0]

                for key, value in data_profile.items():
                    if not value:
                        if key == datafield:
                            data_profile[key] = True
                            break

            data_profile['isLiked'] = True if label == 'liked' else False
                
            profiles.append(data_profile)

            for url_photo in data['photos']:
                with open(f'photos/{label}/{index_photo}.png', 'wb') as f:
                    f.write(requests.get(url_photo).content)

                index_photo += 1

df = pandas.DataFrame.from_records(profiles)
df.to_csv('data_profiles.csv', index=None)

После обработки получился датафрейм со 129 параметрами и 1 таргетом.

Далее следовала обработка изображений. Идея была проста, необходимо было сначала определить лица (я же оцениваю не всю фотографию). Если лиц не было, или их было больше 1, то такие фотографии просто не проходили. Благо, детекцию лиц уже изобрели и есть библиотека face_recognition, решающая эту задачу. Ещё необходимо было немного увеличивать бокс, так как либа очень сильно обрезает причёски. На этом этапе я так же выяснил, какой средний размер у фотографий получился, это понадобилось для настройки модели.

for label in ['liked', 'disliked']:
    for filename in tqdm.notebook.tqdm(os.listdir(f'photos/{label}')):
        image = face_recognition.load_image_file(f'photos/{label}/{filename}')
        positions = face_recognition.face_locations(image)

        if not positions or len(positions) > 1:
            continue

        post_t, pos_r, pos_b, pos_l = positions[0]
        
        _image = PIL.Image.open(f'photos/{label}/{filename}')
        _image = _image.crop((pos_l - 20, post_t - 50, pos_r + 30, pos_b + 10))

        _image.save(f'dataset/{label}/{filename}')

Обучение нейросетей

Сильно останавливаться не буду, хоть и весьма трудозатратная часть вышла, единственный вывод, который сделал, нейросеть может определять вкусы, но очень слабо. По крайней мере, у меня ориентир был на снижение FalseNagative метрики и вышло слабо (это мнение моё). Некоторых метрика accuracy в 60% может даже устроить.

Из общего, накрутил несколько аугментаций, типа, зума и ротации (девушки вертеть любят).

В итоге модель такая получилась:

model = Sequential([
  layers.Resizing(256, 256),
  data_augmentation,
  layers.Rescaling(1./255, input_shape=(img_height, img_width, 3)),
  layers.Conv2D(32, 3, activation='relu'),
  layers.MaxPooling2D(),
  layers.Conv2D(64, 3, activation='relu'),
  layers.MaxPooling2D(),
  layers.Conv2D(128, 3, activation='relu'),
  layers.MaxPooling2D(),
  layers.Conv2D(256, 3, activation='relu'),
  layers.MaxPooling2D(),
  layers.Dropout(0.2),
  layers.Flatten(),
  layers.Dense(256, activation='relu'),
  layers.Dense(2)
])

Оптимизация была BinaryCrossentropy и отслеживал метрику FN.

Не советую использовать, как я понял, структура модели очень сильно зависит от вкусовых предпочтений. К тому же, очень не понравилось поведение функции потерь, она на протяжении нескольких десятков эпох очень сильно скакала (и на обучающей, и на валидационной выборках). Параметров, к слову, вышло не особо много, около 13 млн. в модели. Кстати, думал софтмаксить после Dense(256), но не зашло.

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

df.fillna(False, inplace=True)

train_data = df.iloc[:, 0:128]
train_labels = df['isLiked']

test_data = catboost_pool = Pool(train_data, train_labels)

model = CatBoostClassifier(
  iterations=10,
  depth=10,
  loss_function='Logloss',
  verbose=Tru
)

model.fit(train_data, train_labels)

Зафитпредиктил и нормально. При отборе параметров, решил оставить такие: 10 итераций с глубиной деревьев 10. Остальные по-умолчанию. Этого оказалось достаточно. Дополнительно метрикой можно рассматривать эмпирический подход, просто, заглядывать в FeatureImportance и либо согласиться с моделью, либо отказаться (результаты могут быть смешными).

Сохранил модели и отправил ближе уже к серверу.

Доработка сервера

Тут особо долго не буду останавливаться. Просто роут дополнительный, который принимает так же данные, как и метод save_data, обрабатывает их пайплайном из раздела обработки данных и пихает в модели, на выходе получает несколько вероятностей и из них высчитывает субъективный коэффициент, нравится анкет или, вообще, нет. Этот порог регулируется. Лайки, дизлайки ставятся автоматическим кликом, в зависимости от ответа сервера, клик реализован через банальный вызов метода click() у элемента страницы.

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

Как вы считаете, на сколько этично использовать такие методы?
Вы используете?

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+5
Комментарии8

Публикации

Истории

Работа

Python разработчик
135 вакансий
Data Scientist
82 вакансии

Ближайшие события