Всем привет!

В предыдущих статьях мы уже рассказывали о том, какими метриками можно пользоваться для оценки ответов AI-продуктов.

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

В этой статье мы преследовали две цели:

  1. На примере показать, как применяются такие метрики и как с помощью них можно оценить качество работы модели.

  2. Провести небольшое исследование по различным AI-продуктам с целью выявления наиболее оптимальных для решения задач классификации.

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

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

Каким образом можно организовать классификацию таких кейсов?

  1. Можно выслушать каждого пришедшего клиента и сформировать представление о том, относится ли такой случай к убыткам вообще и к какой именно категории, в частности.

    Плюсы подхода: 

    • можно составить мнение о клиенте;

    • можно узнать важные подробности рассматриваемого случая

      Минусы подхода:

    • затраты времени на информацию, которую можно получить заранее. 

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

    Плюсы:

    • экономия времени на общее ознакомление с делом в случае верного заполнения анкеты

      Минусы:

    • клиент часто не знает, какие конкретно факты необходимы в рассматриваемом случае для правильной юридической классификации;

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

  3. Можно использовать имеющиеся в доступе AI-продукты для выполнения описанной задачи.

    Плюсы:

    • экономия времени на общее ознакомление с делом в случае верной классификации.

      Минусы:

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

  4. Можно создать собственную модель для классификации подобных случаев.

    Плюсы:

    • экономия времени на общее ознакомление с делом

      Минусы:

    • первоначальные временные затраты на создание набора качественных данных и обучение модели.

    • низкое качество работы модели в случае низкого качества подготовленных данных.

  5. Можно использовать уже имеющиеся на рынке модели классификации

    Плюсы:

    • не нужно тратить время на подготовку данных и на обучение собственной модели;

    • обладают высокой степенью качества при решении задач классификации.

      Минусы:

    • могут не поддерживать язык, текст на котором предстоит классифицировать;

    • могут не обладать информацией из конкретной специализированной темы.

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

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

Обучение собственной модели

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

Что можно считать достаточным количеством данных?

по результатам различных исследований называются размеры выборки в диапазоне от 100 до 50000 примеров. Такой разброс обусловлен разными задачами, для решения которых необходимо обучить модель.

Для целей конкретного примера (относится или не относится конкретный случай к понятию реального ущерба) требуется модель с бинарной классификацией.

Подготовка выборки в размере 50000 примеров слишком велика для решения такой задачи.

Мы при подготовке модели для решения простых случаев ограничились 1000, но позднее дополнили еще 200 примерами.

Все примеры готовились следующим образом: в 10 разных категориях было приготовленно по 100 примеров, 50 из которых были положительными (относились к понятию реального ущерба), а другие 50 были, соответственно, отрицательными.

Все примеры были собраны в единую таблицу с заголовками:text, real damage. В колонке text записан текст примера, в колонке real damage - показатели 1 или 0, в зависимости от принадлежности или не принадлежности к реальному ущербу.

Примеры выглядят примерно так.

text

real_damage

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

1

Сломался старый компьютер, но информация была восстановлена

0

Подрядчик повредил несущую стену здания, требуется восстановление

1

Строительство затянулось, и клиент потерял потенциальных покупателей

0

Водитель не справился с управлением и разбил чужую машину

1

Водитель фуры задержался в пути и потерял бонус от заказчика

0

Из-за сбоя биржи инвестор потерял доступ к своим активам

1

Банковский вклад не принес ожидаемой прибыли из-за инфляции

0

В химчистке испортили дорогое пальто

1

Из-за дефицита товаров в магазине не оказалось нужного продукта

0

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

В качестве модели для обучения выбрана “DeepPavlov/rubert-base-cased”. Эта модель уже предварительно обучена на текстах российского сегмента Википедии и новостных статей.

Код для обучения модели выглядит следующим образом:
import pandas as pd
import torch
import numpy as np
from transformers import (
    AutoTokenizer,
    AutoModelForSequenceClassification,
    Trainer,
    TrainingArguments,
    EarlyStoppingCallback,
    DataCollatorWithPadding
)
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
    accuracy_score, 
    precision_recall_fscore_support,
    confusion_matrix,
    classification_report
)
from datasets import Dataset
import matplotlib.pyplot as plt
import seaborn as sns

# === 1. Загрузка данных ===
data = pd.read_csv(r"путь к файлу с данными")

# Разделяем данные
train_texts, val_texts, train_labels, val_labels = train_test_split(
    data["text"], data["real_damage"], test_size=0.2, random_state=42, stratify=data["real_damage"]
)

train_data = Dataset.from_dict({"text": train_texts.tolist(), "label": train_labels.tolist()})
val_data = Dataset.from_dict({"text": val_texts.tolist(), "label": val_labels.tolist()})

# === 2. Токенизация ===
model_name = "DeepPavlov/rubert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_name)

def tokenize_data(batch):
    return tokenizer(batch["text"], padding=False, truncation=True, max_length=512)  # Dynamic padding

train_data = train_data.map(tokenize_data, batched=True, remove_columns=["text"])
val_data = val_data.map(tokenize_data, batched=True, remove_columns=["text"])

train_data.set_format(type="torch", columns=["input_ids", "attention_mask", "label"])
val_data.set_format(type="torch", columns=["input_ids", "attention_mask", "label"])

# === 3. Модель ===
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)

# === 4. Метрики ===
def compute_metrics(eval_pred):
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)
    
    accuracy = accuracy_score(labels, predictions)
    precision, recall, f1, _ = precision_recall_fscore_support(
        labels, predictions, average='binary'
    )
    
    return {
        "accuracy": accuracy,
        "precision": precision,
        "recall": recall,
        "f1": f1
    }

# === 5. Настройки обучения ===
training_args = TrainingArguments(
    output_dir="./results",
    eval_strategy="epoch",
    save_strategy="epoch",
    learning_rate=2e-5,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    num_train_epochs=3,
    weight_decay=0.01,
    logging_dir="./logs",
    logging_steps=10,
    load_best_model_at_end=True,
    metric_for_best_model="f1",
    greater_is_better=True,
    save_total_limit=2  # Сохранять только 2 лучших чекпоинта
)

# Data collator для динамического паддинга
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_data,
    eval_dataset=val_data,
    compute_metrics=compute_metrics,
    data_collator=data_collator,
    callbacks=[EarlyStoppingCallback(early_stopping_patience=2)]
)

# === 6. Обучение ===
print("\n Начало обучения...")
trainer.train()

# === 7. Оценка модели ===
eval_results = trainer.evaluate()
for key, value in eval_results.items():
    print(f"{key}: {value:.4f}")

# Детальная оценка
predictions = trainer.predict(val_data)
preds = np.argmax(predictions.predictions, axis=-1)

print("\n Classification Report:")
print(classification_report(
    val_labels, 
    preds,
    target_names=["Не реальный ущерб", "Реальный ущерб"],
    digits=4
))

# Confusion Matrix
cm = confusion_matrix(val_labels, preds)
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', 
            xticklabels=["Не реальный ущерб", "Реальный ущерб"],
            yticklabels=["Не реальный ущерб", "Реальный ущерб"])
plt.title('Confusion Matrix')
plt.ylabel('True Label')
plt.xlabel('Predicted Label')
plt.tight_layout()
plt.savefig('confusion_matrix.png')

# === 8. Сохранение модели ===
model.save_pretrained("./binary_classification_model")
tokenizer.save_pretrained("./binary_classification_model")

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

test_examples = [
    "На мой автомобиль упал снег с крыши и повредил капот",
    "Из-за задержки возврата техники мы потеряли контракт на 5 млн рублей",
    "Квартира была затоплена соседями сверху, испорчен ремонт",
    "Компания упустила возможность заключить выгодный контракт"
]

for text in test_examples:
    inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
    with torch.no_grad():
        outputs = model(**inputs)
    prediction = torch.argmax(outputs.logits, dim=-1).item()
    confidence = torch.softmax(outputs.logits, dim=-1)[0][prediction].item()
    label = "Реальный ущерб" if prediction == 1 else "Не реальный ущерб"
    print(f"Текст: {text}")
    print(f"Предсказание: {label} (уверенность: {confidence:.2%})\n")

Использование других моделей для классификации

Для выполнения той же задачи с помощью уже существующих моделей есть несколько вариантов:

  1. Для специализированной классификации (например, российское право) лучше использовать локальные модели (YandexGPT, GigaChat и др).

    Плюсы:

    • предпочтительны для специфики конкретной страны или группы стран;

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

      Минусы:

    • все еще в процессе обучения;

    • могут не обладать функционалом для качественной классификации;

    • могут не обладать информацией о событиях и фактах из других стран.

  2. Возможно использовать специализированные модели для решение задач классификации (Google Natural Language API, Yandex Classifier (Few-Shot, Zero-Shot и др.).

    Плюсы:

    • предназначены для выполнения задач классификации

    • способны качественно распределять информацию по заданным критериям

      Минусы:

    • могут не поддерживать локальные языки (не поддерживается Русский язык)

  3. Использование известных моделей для решения подобных задач (ChatGPT, Gemini, Claude и др):

    Плюсы:

    • обладают огромным объемом данных, на которых обучались

    • могут довольно качественно консультировать даже по некоторым специализированным категориям

      Минусы:

    • могут галлюцинировать из-за недостатка данных по вопросу

    • могут неверно определять тональность из-за отсутствия специализации на решении таких задач

Чтобы протестировать качество классификации различных моделей, мы собрали список из 6-8 разных AI-продуктов и протестировали с их помощью качество классификации для двух случаев:

  1. Классификация различных кейсов на предмет отношения к реальному ущербу по ст. 15 ГК РФ (чтобы проверить, каковы возможности разных моделей в специализированной отрасли).

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

Для первого случая были выбраны следующие продукты: BERT, ChatGPT, YandexGPT, Google Natural Language API, Yandex Classifier.

Google Natural Language API был выбран для проверки наличия ограничения при работе с русским текстом. По результатам проверки все тестовые случаи возвращали следующее:

Error: 400 The language ru is not supported for document_sentiment analysis

Остальные AI-продукты были выбраны в следующих модификациях:

  • BERT: предобученная на самостоятельно собранных данных модель;

  • ChatGPT: модели gpt-3.5-turbo и gpt-5-mini;

  • YandexGPT: модели yandexgpt-lite, yandexgpt;

  • Yandex Classifier: Zero-Shot, Few-Shot

Модели ChatGPT, YandexGPT использовались в двух модификациях: без уточняющего промпта и с уточняющим понятие реального ущерба промптом для демонстрации наличия или остутствия возможности классификации в специализированной сфере.

Промпт для уточнения терминологии выглядел так:

CLASSIFICATION_PROMPT_WITH_EXPLANATION = """Ты - эксперт по российскому гражданскому 
праву.

Задача: определить, относится ли описанный случай к понятию "реальный ущерб" согласно 
статье 15 ГК РФ.

Реальный ущерб включает:
1. Утрату или повреждение имущества
2. Расходы на восстановление нарушенного права

НЕ является реальным ущербом:
- Упущенная выгода (неполученные доходы)
- Моральный вред

Проанализируй следующий случай и ответь СТРОГО одним словом без объяснений: "ДА" (если 
это реальный ущерб) или "НЕТ" (если не является реальным ущербом).

ВАЖНО: Твой ответ должен состоять ТОЛЬКО из одного слова "ДА" или "НЕТ", без 
дополнительных пояснений.

Случай: {text}

Ответ:"""

Промпт для задания роли без пояснений выглядел так

CLASSIFICATION_PROMPT_WITHOUT_EXPLANATION = """Определи, относится ли описанный случай 
к понятию "реальный ущерб" согласно российскому гражданскому законодательству.

Ответь СТРОГО одним словом без объяснений: "ДА" или "НЕТ".

ВАЖНО: Твой ответ должен состоять ТОЛЬКО из одного слова "ДА" или "НЕТ", без 
дополнительных пояснений.

Случай: {text}

Ответ:"""

Для Yandex Classifier Zero-Shot был создан следующий промпт:

"""Определи, относится ли описанный случай к понятию "реальный ущерб" согласно статье 15 ГК РФ.

Реальный ущерб включает:
1. Утрату или повреждение имущества
2. Расходы на восстановление нарушенного права

НЕ является реальным ущербом:
- Упущенная выгода (неполученные доходы)
- Моральный вред"""

Для Yandex Classifier Few-shot была добавлена небольшая выборка примеров для использования в работе:


samples = [
    # Примеры реального ущерба
    {"text": "Автомобиль был поврежден в результате ДТП", "label": "Реальный ущерб"},
    {"text": "Затопление квартиры привело к повреждению мебели и ремонта", "label": "Реальный ущерб"},
    {"text": "Расходы на оплату услуг юриста для восстановления права собственности", "label": "Реальный ущерб"},

    # Примеры НЕ реального ущерба
    {"text": "Потеря прибыли из-за срыва контракта", "label": "Не реальный ущерб"},
    {"text": "Моральные страдания от оскорблений", "label": "Не реальный ущерб"},
    {"text": "Недополученный доход от бизнеса", "label": "Не реальный ущерб"},
]

Промпт для такой модели выглядел следуюшим образом:

"""Определи, относится ли случай к понятию 'реальный ущерб' согласно российскому 
законодательству"""

Импортируем все необходимые модули:

import pandas as pd
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import time
from typing import Dict, List, Tuple
import json
import os
from datetime import datetime
from pathlib import Path
import matplotlib.pyplot as plt
import seaborn as sns

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

BERT
class BERTClassifier:
    """Классификатор на основе собственной обученной BERT модели"""

    def __init__(self, model_path: str):
        print("Загрузка BERT модели...")
        self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_path)
        self.model.eval()  # Режим inference
        print("BERT модель загружена")

    def classify(self, text: str) -> Tuple[int, float, float]:
        """
        Классифицирует текст

        Возврат:
            prediction: 0 или 1
            confidence: уверенность в предсказании (0-1)
            time_taken: время выполнения в секундах
        """
        start_time = time.time()

        inputs = self.tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=512)

        with torch.no_grad():
            outputs = self.model(**inputs)

        logits = outputs.logits
        probabilities = torch.softmax(logits, dim=-1)[0]
        prediction = torch.argmax(logits, dim=-1).item()
        confidence = probabilities[prediction].item()

        time_taken = time.time() - start_time

        return prediction, confidence, time_taken

    def batch_classify(self, texts: List[str]) -> List[Dict]:
        """Классифицирует список текстов"""
        results = []
        for text in texts:
            pred, conf, time_taken = self.classify(text)
            results.append({
                "prediction": pred,
                "confidence": conf,
                "time": time_taken,
                "label": "Реальный ущерб" if pred == 1 else "Не реальный ущерб"
            })
        return results
ChatGPT
import openai
import traceback

class OpenAIClassifier:
    """Классификатор на основе OpenAI GPT API"""

    def __init__(self, api_key: str, use_explanation: bool = True):
        self.api_key = api_key
        self.use_explanation = use_explanation
        self.prompt_template = CLASSIFICATION_PROMPT_WITH_EXPLANATION if use_explanation else CLASSIFICATION_PROMPT_WITHOUT_EXPLANATION
        self.client = openai.OpenAI(api_key=api_key)
        
    def classify(self, text: str) -> Tuple[int, float, float]:
        """Классифицирует текст через OpenAI API"""
        # Запуск таймера выполнения теста
        start_time = time.time()

        try:
            system_message = "Ты эксперт по российскому гражданскому праву." if self.use_explanation else "Ты помощник для классификации текстов."
            # Получение ответа от модели
            response = self.client.chat.completions.create(
                model="gpt-5-mini",
                messages=[
                    {"role": "system", "content": system_message},
                    {"role": "user", "content": self.prompt_template.format(text=text)}
                ],
                max_completion_tokens=500  # Установлено для стабильной работы gpt-5-mini
            )
            # Извлечение текста ответа
            message_content = response.choices[0].message.content
            # Очистка текста от лишних пробелов
            answer = message_content.strip()
            # Приведение текста к верхнему регистру
            answer_upper = answer.upper()
            
            # Парсинг ответа
            # Ищем "ДА" или "YES" в начале ответа или как отдельное слово
            if answer_upper.startswith("ДА") or answer_upper.startswith("YES") or " ДА" in answer_upper or " YES" in answer_upper:
                prediction = 1
                confidence = 0.9
            # Ищем "НЕТ" или "NO" в начале ответа или как отдельное слово
            elif answer_upper.startswith("НЕТ") or answer_upper.startswith("NO") or " НЕТ" in answer_upper or " NO" in answer_upper:
                prediction = 0
                confidence = 0.9
            # Дополнительная проверка: если в ответе есть оба слова, выбираем первое встречающееся
            elif "ДА" in answer_upper and "НЕТ" in answer_upper:
                da_pos = answer_upper.find("ДА")
                net_pos = answer_upper.find("НЕТ")
                if da_pos < net_pos:
                    prediction = 1
                    confidence = 0.7
                else:
                    prediction = 0
                    confidence = 0.7
            else:
                # Если ответ непонятен, выводим для отладки
                print(f"Неоднозначный ответ от OpenAI: '{answer}' для текста: '{text[:50]}...'")
                print(f"Finish reason: {response.choices[0].finish_reason}")
                prediction = -1
                confidence = 0.0
            # Вычисление итогового времени на получение классификации
            time_taken = time.time() - start_time
            return prediction, confidence, time_taken

        except Exception as e:
            print(f"Ошибка OpenAI API: {e}")
            traceback.print_exc()
            return -1, 0.0, 0.0

    def batch_classify(self, texts: List[str]) -> List[Dict]:
        """Классифицирует список текстов"""
        results = []
        for text in texts:
            pred, conf, time_taken = self.classify(text)
            results.append({
                "prediction": pred,
                "confidence": conf,
                "time": time_taken,
                "label": "Реальный ущерб" if pred == 1 else ("Не реальный ущерб" if pred == 0 else "Ошибка")
            })
        return results
YandexGPT
import requests

class YandexGPTClassifier:
    """Классификатор на основе YandexGPT API"""

    def __init__(self, api_key: str, folder_id: str, use_explanation: bool = True):
        self.api_key = api_key
        self.folder_id = folder_id
        self.use_explanation = use_explanation
        self.prompt_template = CLASSIFICATION_PROMPT_WITH_EXPLANATION if use_explanation else CLASSIFICATION_PROMPT_WITHOUT_EXPLANATION      
        self.requests = requests

    def classify(self, text: str) -> Tuple[int, float, float]:
        """Классифицирует текст через YandexGPT API"""

        # Запуск таймера выполнения теста
        start_time = time.time()

        # Запрос на выполнение классификации
        try:
            url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"

            headers = {
                "Content-Type": "application/json",
                "Authorization": f"Api-Key {self.api_key}",
                "x-folder-id": self.folder_id
            }

            system_message = "Ты эксперт по российскому гражданскому праву." if self.use_explanation else "Ты помощник для классификации текстов."

            payload = {
                "modelUri": f"gpt://{self.folder_id}/yandexgpt/latest",
                "completionOptions": {
                    "stream": False,
                    "temperature": 0.0,
                    "maxTokens": 10
                },
                "messages": [
                    {
                        "role": "system",
                        "text": system_message
                    },
                    {
                        "role": "user",
                        "text": self.prompt_template.format(text=text)
                    }
                ]
            }
            # Объект ответа
            response = self.requests.post(url, headers=headers, json=payload, timeout=30)
            
            # Проверка HTTP на предмет наличия положительного статуса
            # Если положительного статуса нет (200-299), выводит исключение
            response.raise_for_status()

            # Преобразование json в словарь Python
            result = response.json()
            # Получение текста ответа
            answer = result["result"]["alternatives"][0]["message"]["text"].strip().upper()

            # Парсинг ответа
            if "ДА" in answer or "YES" in answer:
                prediction = 1
                confidence = 0.9
            elif "НЕТ" in answer or "NO" in answer:
                prediction = 0
                confidence = 0.9
            else:
                prediction = -1
                confidence = 0.0

            # Вычисление итогового времени на получение классификации    
            time_taken = time.time() - start_time
            return prediction, confidence, time_taken

        except Exception as e:
            print(f"Ошибка YandexGPT API: {e}")
            return -1, 0.0, 0.0

    def batch_classify(self, texts: List[str]) -> List[Dict]:
        """Классифицирует список текстов"""
        results = []
        for text in texts:
            pred, conf, time_taken = self.classify(text)
            results.append({
                "prediction": pred,
                "confidence": conf,
                "time": time_taken,
                "label": "Реальный ущерб" if pred == 1 else ("Не реальный ущерб" if pred == 0 else "Ошибка")
            })
        return results
Yandex Classifier Zero-Shot
import requests

class YandexZeroShotClassifier:
    """
    Классификатор на основе Yandex Classifier API (Zero-shot)
    Использует специализированный API для классификации без примеров
    """

    def __init__(self, api_key: str, folder_id: str):
        self.api_key = api_key
        self.folder_id = folder_id
        self.first_call = True  # Флаг для отслеживания первого вызова
        self.requests = requests

    def classify(self, text: str) -> Tuple[int, float, float]:
        """Классифицирует текст через Yandex Classifier API (zero-shot)"""

        # Добавляем задержку перед первым запросом
        if self.first_call:
            time.sleep(2.0)
            self.first_call = False
        
        # Запуск таймера выполнения теста
        start_time = time.time()
        
        # Запрос на выполнение классификации
        try:
            url = "https://llm.api.cloud.yandex.net/foundationModels/v1/fewShotTextClassification"

            headers = {
                "Content-Type": "application/json",
                "Authorization": f"Api-Key {self.api_key}",
                "x-folder-id": self.folder_id
            }

            payload = {
                "modelUri": f"cls://{self.folder_id}/yandexgpt/latest",
                "taskDescription": """Определи, относится ли описанный случай к понятию "реальный ущерб" согласно статье 15 ГК РФ.

Реальный ущерб включает:
1. Утрату или повреждение имущества
2. Расходы на восстановление нарушенного права

НЕ является реальным ущербом:
- Упущенная выгода (неполученные доходы)
- Моральный вред""",
                "labels": [
                    "Не реальный ущерб",
                    "Реальный ущерб"
                ],
                "text": text
            }
            
            # Объект ответа
            response = self.requests.post(url, headers=headers, json=payload, timeout=30)
            
            # Проверка HTTP на предмет наличия положительного статуса
            # Если положительного статуса нет (200-299), выводит исключение
            response.raise_for_status()
            
            # Преобразование json в словарь Python
            result = response.json()

            # Получаем предсказания
            predictions = result.get("predictions", [])
            
            # Возврат в случае отсутствия предсказания
            if not predictions:
                return -1, 0.0, 0.0

            # Сортируем по confidence и берем лучший результат
            best_prediction = max(predictions, key=lambda x: x.get("confidence", 0))
            label = best_prediction.get("label", "")
            confidence = best_prediction.get("confidence", 0.0)

            # Преобразуем label в 0/1
            if "Реальный ущерб" in label:
                prediction = 1
            else:
                prediction = 0

            # Вычисление итогового времени на получение классификации      
            time_taken = time.time() - start_time
            return prediction, confidence, time_taken

        except Exception as e:
            print(f"Ошибка Yandex Classifier API: {e}")
            return -1, 0.0, 0.0

    def batch_classify(self, texts: List[str]) -> List[Dict]:
        """Классифицирует список текстов"""
        results = []
        for i, text in enumerate(texts):
            pred, conf, time_taken = self.classify(text)
            results.append({
                "prediction": pred,
                "confidence": conf,
                "time": time_taken,
                "label": "Реальный ущерб" if pred == 1 else ("Не реальный ущерб" if pred == 0 else "Ошибка")
            })
            # Добавляем задержку между запросами для избежания rate limit
            if i < len(texts) - 1:  # Не ждём после последнего запроса
                time.sleep(0.5)  # 500ms задержка
        return results
Yandex Classifier Few-Shot
import requests


class YandexFewShotClassifier:
    """
    Классификатор на основе Yandex Classifier API (Few-shot)
    Использует примеры для обучения классификатора
    """

    def __init__(self, api_key: str, folder_id: str):
        self.api_key = api_key
        self.folder_id = folder_id
        self.first_call = True  # Флаг для отслеживания первого вызова
        self.requests = requests
        
    def classify(self, text: str) -> Tuple[int, float, float]:
        """Классифицирует текст через Yandex Classifier API (few-shot)"""

        # Добавляем задержку перед первым запросом
        if self.first_call:
            time.sleep(2.0)
            self.first_call = False
            
        # Запуск таймера выполнения теста
        start_time = time.time()
        
        # Запрос на выполнение классификации
        try:
            url = "https://llm.api.cloud.yandex.net/foundationModels/v1/fewShotTextClassification"

            headers = {
                "Content-Type": "application/json",
                "Authorization": f"Api-Key {self.api_key}",
                "x-folder-id": self.folder_id
            }

            # Few-shot примеры для обучения
            samples = [
                # Примеры реального ущерба
                {"text": "Автомобиль был поврежден в результате ДТП", "label": "Реальный ущерб"},
                {"text": "Затопление квартиры привело к повреждению мебели и ремонта", "label": "Реальный ущерб"},
                {"text": "Расходы на оплату услуг юриста для восстановления права собственности", "label": "Реальный ущерб"},

                # Примеры НЕ реального ущерба
                {"text": "Потеря прибыли из-за срыва контракта", "label": "Не реальный ущерб"},
                {"text": "Моральные страдания от оскорблений", "label": "Не реальный ущерб"},
                {"text": "Недополученный доход от бизнеса", "label": "Не реальный ущерб"},
            ]

            payload = {
                "modelUri": f"cls://{self.folder_id}/yandexgpt/latest",
                "taskDescription": "Определи, относится ли случай к понятию 'реальный ущерб' согласно российскому законодательству",
                "labels": [
                    "Не реальный ущерб",
                    "Реальный ущерб"
                ],
                "text": text,
                "samples": samples
            }
            # Объект ответа
            response = self.requests.post(url, headers=headers, json=payload, timeout=30)
            
            # Проверка HTTP на предмет наличия положительного статуса
            # Если положительного статуса нет (200-299), выводит исключение 
            response.raise_for_status()

            # Преобразование json в словарь Python
            result = response.json()

            # Получаем предсказания
            predictions = result.get("predictions", [])

            # Возврат в случае отсутствия предсказания
            if not predictions:
                return -1, 0.0, 0.0

            # Сортируем по confidence и берем лучший результат
            best_prediction = max(predictions, key=lambda x: x.get("confidence", 0))
            label = best_prediction.get("label", "")
            confidence = best_prediction.get("confidence", 0.0)

            # Преобразуем label в 0/1
            if "Реальный ущерб" in label:
                prediction = 1
            else:
                prediction = 0

            # Вычисление итогового времени на получение классификации    
            time_taken = time.time() - start_time
            return prediction, confidence, time_taken

        except Exception as e:
            print(f"Ошибка Yandex Few-shot Classifier API: {e}")
            return -1, 0.0, 0.0

    def batch_classify(self, texts: List[str]) -> List[Dict]:
        """Классифицирует список текстов"""
        results = []
        for i, text in enumerate(texts):
            pred, conf, time_taken = self.classify(text)
            results.append({
                "prediction": pred,
                "confidence": conf,
                "time": time_taken,
                "label": "Реальный ущерб" if pred == 1 else ("Не реальный ущерб" if pred == 0 else "Ошибка")
            })
            # Добавляем задержку между запросами для избежания rate limit
            if i < len(texts) - 1:  # Не ждём после последнего запроса
                time.sleep(0.5)  # 500ms задержка
        return results
Проведение самих тестов выглядит следующим образом
# Создаем папку для результатов
RESULTS_DIR = Path("relation_classification_comparison_results")
RESULTS_DIR.mkdir(exist_ok=True)

CONFIG = {
    "bert_model_path": r"путь к модели BERT",
    "openai_api_key": os.getenv("OPENAI_API_KEY", ""),
    "yandex_api_key": os.getenv("YANDEX_API_KEY", ""),
    "yandex_folder_id": os.getenv("YANDEX_FOLDER_ID", ""),
}

def calculate_metrics(predictions: List[int], labels: List[int]) -> Dict[str, float]:
    """Вычисляет метрики качества классификации"""

    valid_pairs = [(p, l) for p, l in zip(predictions, labels) if p != -1]

    if not valid_pairs:
        return {
            "accuracy": 0.0,
            "precision": 0.0,
            "recall": 0.0,
            "f1": 0.0,
            "valid_predictions": 0
        }

    predictions_valid = [p for p, l in valid_pairs]
    labels_valid = [l for p, l in valid_pairs]

    tp = sum(1 for p, l in zip(predictions_valid, labels_valid) if p == 1 and l == 1)
    fp = sum(1 for p, l in zip(predictions_valid, labels_valid) if p == 1 and l == 0)
    tn = sum(1 for p, l in zip(predictions_valid, labels_valid) if p == 0 and l == 0)
    fn = sum(1 for p, l in zip(predictions_valid, labels_valid) if p == 0 and l == 1)

    accuracy = (tp + tn) / len(predictions_valid) if predictions_valid else 0
    precision = tp / (tp + fp) if (tp + fp) > 0 else 0
    recall = tp / (tp + fn) if (tp + fn) > 0 else 0
    f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0

    return {
        "accuracy": accuracy,
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "valid_predictions": len(predictions_valid),
        "tp": tp,
        "fp": fp,
        "tn": tn,
        "fn": fn
    }


def compare_methods(test_cases: List[Dict]) -> Tuple[pd.DataFrame, Dict]:
    """Сравнивает все методы классификации"""

    classifiers = {
        "BERT (собственная модель)": BERTClassifier(CONFIG["bert_model_path"]),
        "OpenAI GPT (с объяснением)": OpenAIClassifier(CONFIG["openai_api_key"], use_explanation=True),
        "OpenAI GPT (без объяснения)": OpenAIClassifier(CONFIG["openai_api_key"], use_explanation=False),
        "YandexGPT (с объяснением)": YandexGPTClassifier(CONFIG["yandex_api_key"], CONFIG["yandex_folder_id"], use_explanation=True),
        "YandexGPT (без объяснения)": YandexGPTClassifier(CONFIG["yandex_api_key"], CONFIG["yandex_folder_id"], use_explanation=False),
        "Yandex Classifier (zero-shot)": YandexZeroShotClassifier(CONFIG["yandex_api_key"], CONFIG["yandex_folder_id"]),
        "Yandex Classifier (few-shot)": YandexFewShotClassifier(CONFIG["yandex_api_key"], CONFIG["yandex_folder_id"]),
    }

    texts = [case["text"] for case in test_cases]
    true_labels = [case["label"] for case in test_cases]
    categories = [case["category"] for case in test_cases]

    all_results = {}

    for method_name, classifier in classifiers.items():

        results = classifier.batch_classify(texts)
        all_results[method_name] = results

        predictions = [r["prediction"] for r in results]
        metrics = calculate_metrics(predictions, true_labels)

        print(f"✓ Обработано примеров: {len(texts)}")
        print(f"✓ Валидных предсказаний: {metrics['valid_predictions']}")
        print(f"✓ Accuracy: {metrics['accuracy']:.2%}")
        print(f"✓ Precision: {metrics['precision']:.2%}")
        print(f"✓ Recall: {metrics['recall']:.2%}")
        print(f"✓ F1-score: {metrics['f1']:.2%}")

        avg_time = sum(r["time"] for r in results) / len(results) if results else 0
        print(f"✓ Среднее время на пример: {avg_time:.3f} сек")

    comparison_data = []

    for i, case in enumerate(test_cases):
        row = {
            "Текст": case["text"][:60] + "..." if len(case["text"]) > 60 else case["text"],
            "Категория": case["category"],
            "Истина": "Да" if case["label"] == 1 else "Нет"
        }

        for method_name, results in all_results.items():
            pred = results[i]["prediction"]
            conf = results[i]["confidence"]

            if pred == -1:
                row[f"{method_name}"] = "Ошибка"
                row[f"{method_name} (уверенность)"] = "N/A"
            else:
                row[f"{method_name}"] = "Да" if pred == 1 else "Нет"
                row[f"{method_name} (уверенность)"] = f"{conf:.2%}"

        comparison_data.append(row)

    df = pd.DataFrame(comparison_data)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    output_file = RESULTS_DIR / f"classification_comparison_{timestamp}.csv"
    df.to_csv(output_file, index=False, encoding="utf-8-sig")
    print(f"\n Результаты сохранены в файл: {output_file}")

    return df, all_results


def visualize_results(all_results: Dict, test_cases: List[Dict]):
    """Создает визуализации результатов сравнения"""

    true_labels = [case["label"] for case in test_cases]

    methods = []
    accuracies = []
    f1_scores = []
    avg_times = []
    colors_list = []

    for method_name, results in all_results.items():
        predictions = [r["prediction"] for r in results]
        metrics = calculate_metrics(predictions, true_labels)

        if metrics["valid_predictions"] > 0:
            short_name = method_name.replace(" (собственная модель)", "")

            if "BERT" in method_name:
                color = '#2ecc71'
            elif "без объяснения" in method_name:
                color = '#e74c3c'
            elif "с объяснением" in method_name:
                color = '#3498db'
            else:
                color = '#95a5a6'

            methods.append(short_name)
            accuracies.append(metrics["accuracy"] * 100)
            f1_scores.append(metrics["f1"] * 100)
            avg_times.append(sum(r["time"] for r in results) / len(results))
            colors_list.append(color)

    # График 1: Accuracy и F1-score
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))

    x = range(len(methods))

    bars1 = ax1.bar(x, accuracies, color=colors_list, alpha=0.8, edgecolor='black', linewidth=1.2)
    ax1.set_ylabel('Accuracy (%)', fontsize=12)
    ax1.set_title('Сравнение точности методов классификации отношений', fontsize=14, fontweight='bold')
    ax1.set_xticks(x)
    ax1.set_xticklabels(methods, rotation=25, ha='right', fontsize=9)
    ax1.grid(axis='y', alpha=0.3)
    ax1.set_ylim(0, 105)

    for i, (bar, acc) in enumerate(zip(bars1, accuracies)):
        height = bar.get_height()
        ax1.text(bar.get_x() + bar.get_width()/2., height + 1,
                f'{acc:.1f}%', ha='center', va='bottom', fontsize=9, fontweight='bold')

    from matplotlib.patches import Patch
    legend_elements = [
        Patch(facecolor='#2ecc71', label='BERT (обученная модель)'),
        Patch(facecolor='#3498db', label='LLM с объяснением'),
        Patch(facecolor='#e74c3c', label='LLM без объяснения')
    ]
    ax1.legend(handles=legend_elements, loc='lower right', fontsize=9)

    bars2 = ax2.bar(x, f1_scores, color=colors_list, alpha=0.8, edgecolor='black', linewidth=1.2)
    ax2.set_ylabel('F1-score (%)', fontsize=12)
    ax2.set_title('F1-score для каждого метода', fontsize=14, fontweight='bold')
    ax2.set_xticks(x)
    ax2.set_xticklabels(methods, rotation=25, ha='right', fontsize=9)
    ax2.grid(axis='y', alpha=0.3)
    ax2.set_ylim(0, 105)

    for i, (bar, f1) in enumerate(zip(bars2, f1_scores)):
        height = bar.get_height()
        ax2.text(bar.get_x() + bar.get_width()/2., height + 1,
                f'{f1:.1f}%', ha='center', va='bottom', fontsize=9, fontweight='bold')

    plt.tight_layout()
    output_file = RESULTS_DIR / 'classification_methods_comparison.png'
    plt.savefig(output_file, dpi=300, bbox_inches='tight')
    print(f"График сохранен: {output_file}")

    # График 2: Время выполнения
    fig, ax3 = plt.subplots(figsize=(12, 6))
    time_colors = ['#2ecc71' if t < 0.1 else '#f39c12' if t < 1.0 else '#e74c3c' for t in avg_times]
    bars3 = ax3.bar(x, avg_times, color=time_colors, alpha=0.8, edgecolor='black', linewidth=1.2)
    ax3.set_ylabel('Время (секунды)', fontsize=12)
    ax3.set_title('Среднее время классификации одного примера', fontsize=14, fontweight='bold')
    ax3.set_xticks(x)
    ax3.set_xticklabels(methods, rotation=25, ha='right', fontsize=9)
    ax3.grid(axis='y', alpha=0.3)

    for i, (bar, t) in enumerate(zip(bars3, avg_times)):
        height = bar.get_height()
        ax3.text(bar.get_x() + bar.get_width()/2., height + 0.01,
                f'{t:.3f}s', ha='center', va='bottom', fontsize=9, fontweight='bold')

    time_legend = [
        Patch(facecolor='#2ecc71', label='Быстро (<0.1с)'),
        Patch(facecolor='#f39c12', label='Средне (0.1-1с)'),
        Patch(facecolor='#e74c3c', label='Медленно (>1с)')
    ]
    ax3.legend(handles=time_legend, loc='upper right', fontsize=9)

    plt.tight_layout()
    output_file = RESULTS_DIR / 'classification_time_comparison.png'
    plt.savefig(output_file, dpi=300, bbox_inches='tight')
    print(f"График времени сохранен: {output_file}")

    # График 3: Confusion Matrix
    # Создаем mapping для безопасного доступа к axes
    method_to_idx = {method_name: idx for idx, method_name in enumerate(methods)}

    fig, axes = plt.subplots(1, len(methods), figsize=(5*len(methods), 4))

    if len(methods) == 1:
        axes = [axes]

    for method_name, results in all_results.items():
        predictions = [r["prediction"] for r in results if r["prediction"] != -1]
        labels_valid = [true_labels[i] for i, r in enumerate(results) if r["prediction"] != -1]

        method_short = method_name.replace(" (собственная модель)", "")

        # Проверяем, что метод есть в списке methods (т.е. имеет валидные предсказания)
        if method_short in method_to_idx and predictions:
            idx = method_to_idx[method_short]
            from sklearn.metrics import confusion_matrix
            cm = confusion_matrix(labels_valid, predictions)

            sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=axes[idx],
                       xticklabels=["Нет", "Да"],
                       yticklabels=["Нет", "Да"])
            axes[idx].set_title(method_short, fontweight='bold', fontsize=10)
            axes[idx].set_ylabel('Истинная метка')
            axes[idx].set_xlabel('Предсказание')

    plt.tight_layout()
    output_file = RESULTS_DIR / 'confusion_matrices_comparison.png'
    plt.savefig(output_file, dpi=300, bbox_inches='tight')
    print(f"Confusion matrices сохранены: {output_file}")

    plt.close('all')

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

 Модель

Accuracy

Precision

Recall

F1-score

Время (сек)

Верные ответы

YandexGPT (yandexgpt-lite с объяснением терминологии)

100.00%

100.00%

100.00%

100.00%

0.341

14/14

YandexGPT (yandexgpt с объяснением терминологии)

100.00%

100.00%

100.00%

100.00%

0.356

14/14

OpenAI GPT-5-mini (с объяснением терминологии)

100.00%

100.00%

100.00%

100.00%

3.260

14/14

OpenAI GPT-3.5-turbo (с объяснением терминологии)

100.00%

100.00%

100.00%

100.00%

0,748

14/14

Yandex Classifier (zero-shot)

100.00%

100.00%

100.00%

100.00%

0.166

10/10

Yandex Classifier (few-shot)

100.00%

100.00%

100.00%

100.00%

0.187

11/11

BERT

92.86%

88.89%

100.00%

94.12%

0.136

13/14

YandexGPT (yandexgpt без объяснения терминологии)

92.86%

88.89%

100.00%

94.12%

0.336

13/14

OpenAI GPT-5-mini (без объяснения терминологии)

85.71%

80.00%

100.00%

88.89%

4.813

14/14

YandexGPT (yandexgpt-lite без объяснения терминологии)

78.57%

77.78%

87.50%

82.35%

0.422

11/14

OpenAI GPT-3.5-mini (без объяснения терминологии)

64.29%

61.54%

100.00%

76.19%

0.640

9/14

Даже на такой небольшой выборке в 14 тестов можно увидеть различия в качестве работы моделей.

Самыми эффективными с точки зрения точности и быстроты классификации оказались модели YandexGPT с поясняющим терминологию промтом.

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

Модели Yandex Classifier, хоть и не допустили ошибок, при классификации требуют дополнительных настроек для соблюдения лимита количества запросов в единицу времени, поэтому в 3 и 4 случаях из выборки в 14 тестов было зафиксировано исключение (HTTP код 429).

Модели от OpenAI и YandexGPT куда хуже себя ведут в специализированных областях без поясняющих промптов.

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

Промпт для уточнения критериев классификации выглядит так:

CLASSIFICATION_PROMPT_WITH_EXPLANATION = """Ты - эксперт по анализу текстов на русском 
языке.

Задача: определить, относится ли объект или явление, упомянутое в предложении, 
к чему-либо.

Примеры:
- "Митохондрии выполняют роль энергетических станций клетки" → 
Относится (митохондрии относятся к клетке)
- "Растения не содержат гликогена" → 
Не относится (гликоген не содержится в растениях)
- "JavaScript является частью веб-разработки" → 
Относится (JavaScript относится к веб-разработке)
- "DNS-серверы не связаны с обработкой изображений" → 
Не относится (DNS-серверы не связаны с этим)

Проанализируй предложение и ответь СТРОГО одним словом без объяснений: 
"ДА" (если объект/явление относится к чему-то) или "НЕТ" (если не относится).

ВАЖНО: Твой ответ должен состоять ТОЛЬКО из одного слова "ДА" или "НЕТ", без 
дополнительных пояснений.

Предложение: {text}

Ответ:"""

Промпт без дополнительных объяснений критериев выглядит следующим образом:

CLASSIFICATION_PROMPT_WITHOUT_EXPLANATION = """Относится ли объект или явление в 
предложении к чему-то?

Предложение: {text}

Ответь ТОЛЬКО "ДА" или "НЕТ" (одно слово):"""

Модели были выбраны те же, а вот результаты тестирования оказались несколько иными.

Модель

Accuracy

Precision

Recall

F1-score

Время (сек)

Верные ответы

BERT

100%

100%

100%

100%

0,095-0,139

30/30

YandexGPT (yandexgpt с объяснением критериев оценки)

100%

100%

100%

100%

0,388

30/30

Yandex Classifier (zero-shot)

100%

100%

100%

100%

1,970

30/30

Yandex Classifier (few-shot)

100%

100%

100%

100%

2,370

30/30

YandexGPT (yandexgpt без объяснения критериев оценки)

93.33%

93.33%

93.33%

93.33%

1,900

28/30

OpenAI GPT-5-mini (с объяснением критериев оценки)

93.33%

93.33%

93.33%

93.33%

3,989

28/30

OpenAI GPT-3.5-turbo (с объяснением критериев оценки)

86.67%

78.95%

100.00%

88.24%

0,750

26/30

OpenAI GPT-3.5-turbo (без объяснения критериев оценки)

83.33%

75.00%

100.00%

85.71%

0,613

25/30

OpenAI GPT-5-mini (без объяснения критериев оценки)

66.67%

62.50%

100.00%

76.92%

4,693

18/27

YandexGPT (yandexgpt-lite с объяснением критериев оценки)

60.00%

55.56%

100.00%

71.43%

0,468

18/30

YandexGPT (yandexgpt-lite без объяснения критериев оценки)

50.00%

50.00%

100.00%

66.67%

0,359

15/30

Самыми успешными в классификации по заданным критериям оказались предварительно обученная собственная модель BERT и yandexgpt с объяcнением критериев оценки.

Модели Yandex Classifier тоже показали себя хорошо. Процент успешных решений варьируется от 96,67% до 100%. С помощью изменений в коде исполнения запросов были решены проблемы с отображением ошибки HTTP 429, но из-за этого увеличилось время на проведение тестов.

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

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

Также, вследствие отказа от параметра temperature в модели gpt5-mini наблюдается нестабильность в проведении тестов (3 тест кейса из 30 завершились пустым выводом).

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

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

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

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