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

YandexGPT для распознавания навыков в резюме без смс и разметки данных

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров6.6K

Салют! Меня зовут Григорий, и я главный по спецпроектам в команде AllSee. На дворе 2024 год — год ИИ и больших языковых моделей, многие из нас уже приручили новые технологии и вовсю используют их для всего подряд: написания кода, решения рабочих и учебных задач, борьбы с одиночеством. Давайте и мы попробуем применить LLM для решения одной интересной задачки из сферы HR. Сегодня в меню автоматическое определение навыков кандидата по тексту резюме. Поехали?

Мотивация

В рамках одного из наших многочисленных спецпроектов, перед нами встала задача распознавания навыков кандидата из заранее заданного списка на основе резюме.

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

Обычно подобные задачи решают методами прямого поиска внутри текста, однако данный подход требует ручного заполнения словарей лексем. Нейросетевые методы NER (Named Entity Recognition) в данной задаче также затруднительно применять из‑за необходимости разметки большого количества данных, а также ограничения вариантов ответов заранее заданным списком.

Какие вводные?

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

Важно также ответить следующие требования:

  1. Наш алгоритм должен работать на русском языке

  2. У нас нет размеченного датасета и мы хотим отказаться от разметки обучающего множества

Для оценки алгоритма мы ограничимся разметкой только тестовой выборки в размере 20 резюме.

Решение

Yandex GPT API

В нашем решении будем использовать YandexGPT API. Важно также отметить, что используя GPT (generative pre‑trained transformer) мы убираем необходимость в разметке обучающей выборки: первичные результаты и работающий алгоритм мы можем получить путём промпт‑инжиниринга, оставляя при этом за нами право файнтьюнинга модели в будущем (при наличии размеченного датасета).

Для более простого взаимодействия с YandexGPT API используем YandexGPT Python SDK — python‑обёртку над API с автоматической авторизацией и обработкой запросов.

Авторизация в YandexGPT API

Для использования YandexGPT API необходимо указать ID каталога Yandex Cloud и URI нужной нам модели. Подробнее о том, где их взять можно почитать в статье или в официальной документации YandexGPT API.

Так как мы используем YandexGPT Python SDK, для авторизации нам достаточно задать следующие переменные окружения:

# YandexGPT model type. Currently supported: yandexgpt, yandexgpt-light, summarization
YANDEX_GPT_MODEL_TYPE=yandexgpt

# YandexGPT catalog ID. How to get it: https://yandex.cloud/ru/docs/iam/operations/sa/get-id
YANDEX_GPT_CATALOG_ID=abcde12345

# API key. How to get it: https://yandex.cloud/ru/docs/iam/operations/api-key/create
YANDEX_GPT_API_KEY=AAA111-BBB222-CCC333

Предобработка входных данных

Перейдём непосредственно к работе с текстом. Хотелось бы сказать модели: «Вот тебе текст, вот тебе список навыков. Найди в тексте навыки из списка». План надёжный, но, как и в любой задаче, есть «подводные камни». В целом, работа с «камнями» достаточно полезна: пытаясь разобраться с какой‑либо задачей мы более глубоко изучаем доступные нам инструменты, чтобы настроить их на решение наших проблем.

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

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

  2. Модель путается в большом наборе возможных навыков. Решаем данную проблему делением списка навыков на батчи.

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

Код для предобработок
from typing import List, Dict


def split_text_to_batches(text: str, batch_size: int) -> List[str]:
    return [text[i:i + batch_size] for i in range(0, len(text), batch_size)]


def split_list_to_batches(elements_list: List[str], batch_size: int) -> List[List[str]]:
    return [elements_list[i:i + batch_size] for i in range(0, len(elements_list), batch_size)]


def filter_special_simbols(text: str) -> str:
    return text.replace("\n", " ").replace("\xa0", " ")

Запросы к YandexGPT

Подготовим промпт для обработки наших данных. На какие моменты стоит обратить внимание?

  1. Указание роли ассистента

  2. Разделение на system и user промпты

  3. Однозначность инструкций

  4. Требования к формату ответа

Код для запросов к YandexGPT
import time
import asyncio
from typing import List, Dict

from yandex_gpt import YandexGPTConfigManagerForAPIKey, YandexGPT

from dotenv import load_dotenv
load_dotenv("./env/.env.api_key")


async def process_message(message, yandex_gpt, sem, start_time):
    try:
        async with sem:
            if time.time() - start_time > 55:
                return ""
            await asyncio.sleep(1)
            
            result = yandex_gpt.get_sync_completion(messages=message, temperature=0.0, max_tokens=1000)
            return result
        
    except Exception as e:
        print("Error in process_message:", e)
        return ""

      
async def extract_skills_from_pdf(resume_text: str, skills: List[Dict]):
    try:
        resume_batch_size = 300
        skills_batch_size = 30

        resume_text_batches = split_text_to_batches(resume_text, resume_batch_size)
        skills_batches = split_list_to_batches([skill["skill"] for skill in skills], skills_batch_size)
        messages = []

        for resume_text_batch in resume_text_batches:
            for skills_batch in skills_batches:
                messages.append([
                    {'role': 'system', 'text': (
                        'Ты опытный HR-консультант. '
                        f'Набор возможных навыков кандидата: {"; ".join([f"\"{skill}\"" for skill in skills_batch])}. '
                        'Из отрывка резюме кандидата выдели упоминаемые им компетенции и навыки, если они присутствуют в списке возможных навыков кандидата. '
                        "Ищи не только прямые упоминания навыков, но и косвенные признаки их наличия. "
                        "Для ответа используй точные формулировки из набора навыков. "
                        'Ответ дай в формате списка навыков, отделяя каждый навык точкой с запятой (";"): "навык1; навык2; навык3; ...". '
                        'Если данный отрывок не относится к информации о навыках, верни пустую строку ("").'
                    )},
                    {'role': 'user', 'text': (
                        f'Отрывок резюме кандидата: "{filter_special_simbols(resume_text_batch)}".'
                    )}
                ])

        sem = asyncio.Semaphore(1)
        tasks = [process_message(message, yandex_gpt, sem, start_time=start_time) for message in messages]
        results = await asyncio.gather(*tasks)

        res = set()
        for skill in [find_elements_in_text([skill["skill"] for skill in skills], text) for text
                      in results]:
            res = res | set(skill)

        reverse_skills_mapping = {skill["skill"]: skill["id"] for skill in skills}
        return [(reverse_skills_mapping[skill], skill) for skill in res]

    except Exception as e:
        print("Error in extract_skills_from_pdf:", e)
        return []

Обработка ответа модели

Вернёмся к нашим «камням»:

  1. Модель иногда игнорирует промпт и выдаёт список в формате «булетов» или с разными разделителями. Удаляем любые возможные разделители.

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

Код для извлечения списка навыков
import time
import asyncio
from typing import List, Dict


def find_elements_in_text(elements_list: List[str], text: str) -> List[str]:
    lower_text = text.lower().replace("\n", " ").replace("*", " ").replace('"', "")
    words = lower_text.split("; ")

    elements_count = {element.lower(): words.count(element.lower()) for element in elements_list}

    elements_mapping = {element.lower(): element for element in elements_list}
    filtered_elements = [elements_mapping[element] for element, count in elements_count.items() if count > 0]

    return filtered_elements

Полный код решения

Тык
import time
import asyncio
from typing import List, Dict

from yandex_gpt import YandexGPTConfigManagerForAPIKey, YandexGPT

from dotenv import load_dotenv
load_dotenv("./env/.env.api_key")


def split_text_to_batches(text: str, batch_size: int) -> List[str]:
    return [text[i:i + batch_size] for i in range(0, len(text), batch_size)]


def split_list_to_batches(elements_list: List[str], batch_size: int) -> List[List[str]]:
    return [elements_list[i:i + batch_size] for i in range(0, len(elements_list), batch_size)]


def filter_special_simbols(text: str) -> str:
    return text.replace("\n", " ").replace("\xa0", " ")


async def process_message(message, yandex_gpt, sem, start_time):
    try:
        async with sem:
            if time.time() - start_time > 55:
                return ""
            await asyncio.sleep(1)
            
            result = yandex_gpt.get_sync_completion(messages=message, temperature=0.0, max_tokens=1000)
            return result
        
    except Exception as e:
        print("Error in process_message:", e)
        return ""

      
def find_elements_in_text(elements_list: List[str], text: str) -> List[str]:
    lower_text = text.lower().replace("\n", " ").replace("*", " ").replace('"', "")
    words = lower_text.split("; ")

    elements_count = {element.lower(): words.count(element.lower()) for element in elements_list}

    elements_mapping = {element.lower(): element for element in elements_list}
    filtered_elements = [elements_mapping[element] for element, count in elements_count.items() if count > 0]

    return filtered_elements

      
async def extract_skills_from_pdf(resume_text: str, skills: List[Dict]):
    try:
        resume_batch_size = 300
        skills_batch_size = 30

        resume_text_batches = split_text_to_batches(resume_text, resume_batch_size)
        skills_batches = split_list_to_batches([skill["skill"] for skill in skills], skills_batch_size)
        messages = []

        for resume_text_batch in resume_text_batches:
            for skills_batch in skills_batches:
                messages.append([
                    {'role': 'system', 'text': (
                        'Ты опытный HR-консультант. '
                        f'Набор возможных навыков кандидата: {"; ".join([f"\"{skill}\"" for skill in skills_batch])}. '
                        'Из отрывка резюме кандидата выдели упоминаемые им компетенции и навыки, если они присутствуют в списке возможных навыков кандидата. '
                        "Ищи не только прямые упоминания навыков, но и косвенные признаки их наличия. "
                        "Для ответа используй точные формулировки из набора навыков. "
                        'Ответ дай в формате списка навыков, отделяя каждый навык точкой с запятой (";"): "навык1; навык2; навык3; ...". '
                        'Если данный отрывок не относится к информации о навыках, верни пустую строку ("").'
                    )},
                    {'role': 'user', 'text': (
                        f'Отрывок резюме кандидата: "{filter_special_simbols(resume_text_batch)}".'
                    )}
                ])

        sem = asyncio.Semaphore(1)
        tasks = [process_message(message, yandex_gpt, sem, start_time=start_time) for message in messages]
        results = await asyncio.gather(*tasks)

        res = set()
        for skill in [find_elements_in_text([skill["skill"] for skill in skills], text) for text
                      in results]:
            res = res | set(skill)

        reverse_skills_mapping = {skill["skill"]: skill["id"] for skill in skills}
        return [(reverse_skills_mapping[skill], skill) for skill in res]

    except Exception as e:
        print("Error in extract_skills_from_pdf:", e)
        return []

Результаты работы алгоритма

Для оценки результатов работы алгоритма будем использовать Precision и Recall алгоритма: долю верных навыков в ответе модели и долю обнаруженных навыков среди верных соответственно. Отдавать приоритет при оценке будем именно Recall, ведь в случае ручной проверки нам проще «нажать на крестик», чем выполнить поиск по базе навыков.

По результатам первичных тестов имеем Precision и Recall равными 69% и 78% соответственно. Для первого приближения результаты достойные, учитывая, что мы не использовали файнтьюниг модели, а просто немного поэксперементировали с промптами.

Заключение

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

Буду рад обсудить все интересующие вопросы в комментариях. Удачи и будем на связи✌️

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+13
Комментарии12

Публикации

Истории

Работа

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

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