Девушка мечты ("представление" 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 я пытался оптимизировать, вы, просто, можете пропустить жемчужину.

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

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


  1. LordDarklight
    19.06.2024 11:13

    Всё что не является прямым взаимодействием с человеком всё этично! Один робот - взламывает другого робота, который как раз ведёт себя очень неэтично по сравнению с клиентом человеком - так что так ему и надо!

    Что до общения.... тут сложнее... но я скажу так - если бы сервис изначально мог предоставлять обширные сведения о каждом кандидате, которые можно было бы статистически обработать и сопоставить с необходимым шаблоном - то и общаться-то не пришлось бы - 99.8... % (в зависимости от глубины шаблона и данных) кандидатов сразу бы отсеялись! А так как такого сервиса ни у кого нет - все претензии прошу предъявлять именно дейтинг сервису - который не про удобство и любовь - а просто бизнес и ничего личного! Так что тут я тоже за интеллектуальное автоматизированное "продолжение банкета" - во всех самых жёстких формах - пусть дейтинг сервисы завалят жалобами - иначе они и вовсе не изменятся!

    P.S.

    Но вообще завидую тем - у кого проблема со временем самостоятельно пообщаться с первично отобранными кандидатами - т.е. объём тех, кто хотя бы ответить, и длинна диалога превысит 3-4 сообщения настолько велик, что будет отнимать больше времени чем постоянный тюнинг такой вот системы!


    1. iv_kingmaker Автор
      19.06.2024 11:13

      С одной стороны даже согласен (по поводу первичного с кандидаткой общения). Так как, банально первичное общение сводится к единообразным вопросам и рассказе о себе, иногда приходится, просто, даже копировать сообщения. Есть целый пласт девушек, которых общие вопросы (о работе, учёбе) не устраивают и они сразу прекращают общение, якобы это без всякого креатива (я не говорю про вопросы "как дела"). Я же не клоун, развлекать другого собеседника. Это смешно немного, информации у них такой в анкете нет, но при этом, как можно начать диалог с людьми, не узнав базовой информации о них? Логика удивляет.

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


      1. LordDarklight
        19.06.2024 11:13
        +1

        Если общение сразу не заладилось - оно не заладится и далее! Подстроить начало общения под каждого кандидата не обладая почти никакими знаниями о нём - практически невозможно!

        И даже стратегию - аналитического прощупывания с разных сторон (и разных эккаунтов) не провернуть - из-за привязки к фотографиям на этих эккаунтах!

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

        Но, опять же - говоря об продолжающемся общении - смотрите на постскриптум в моём предыдущем сообщении - в нём 50% всей сути (остальные 50% - это 25 + 25 = "Бездушная система и аудитория, с бездушным же подходом к тем, кто уделил им толику своего бесценного внимания" + "Только бизнес ничего личного"... ну есть ещё некоторый % как "Развод лохов", хотя в целом это часть "Только бизнес ничего личного", просто в другой плоскости)


  1. SnakeSolid
    19.06.2024 11:13

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

    Как мне кажется, сейчас такая автоматизация - это единственный способ пользоваться приложениями для знакомств. Из-за очень низкой конверсии из лайков в матчи, приходится либо весь день сидеть и лайкать, либо автоматизировать. Я, для себя, выбрал второе.

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


    1. iv_kingmaker Автор
      19.06.2024 11:13

      Да, тоже были мысли про тату, цвет глаз, даже цвет волос. Но решил не оверинжинирить и показать концепцию людям, эту концепцию уже можно развивать.

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


      1. SnakeSolid
        19.06.2024 11:13

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

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

        PS: мне еще хотелось как-то вес определять, но готовых сетей для этого не нашел. Поэтому он тоже через llava пойдет, наряду с первой фразой для знакомства.


  1. torbasow
    19.06.2024 11:13
    +1

    "Я вспомнил, как Ромеро рассказывал смешную историю. Нашлись два романтика, мужчина и девушка, до того уверовавшие в безошибочность Справочной, что всерьёз поручили ей отыскать себе пару. И Справочная, перебрав всех жителей Земли, свела именно их, как максимально пригодных для совместной жизни. Теперь дело оставалось за тем, чтоб встретиться и влюбиться. Они встретились и почувствовали друг к другу отвращение" (С. Снегов. Галактическая разведка).


  1. shiru8bit
    19.06.2024 11:13
    +1

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