Собираем свой аналог Jev на бесплатном GPU в Colab. google colab.. google colab. Jev.. google colab. Jev. llm.. google colab. Jev. llm. python.. google colab. Jev. llm. python. PyTorch.. google colab. Jev. llm. python. PyTorch. System One.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud. инференс.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud. инференс. искусственный интеллект.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud. инференс. искусственный интеллект. классификация текста.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud. инференс. искусственный интеллект. классификация текста. логиты.. google colab. Jev. llm. python. PyTorch. System One. timeweb_статьи. transformers. Блог компании Timeweb Cloud. инференс. искусственный интеллект. классификация текста. логиты. Машинное обучение.
Собираем свой аналог Jev на бесплатном GPU в Colab - 1

15 сентября 2026 года TypeSafe AI выпустила Jev — модель класса «System One»: она получает состояние и типизированные вопросы, а возвращает варианты, оценки и вероятности без единого слова текста. Сразу после выхода Jev аналоги побежали делать все кому не лень: Liquid AI, например, показала d1 уже через две недели. А мы чем хуже? Ну, по качеству, скорее всего, хуже. Зато бесплатно.

Сейчас мы разберём, что происходит внутри языковой модели, когда она «отвечает» одной буквой, и соберём из этого простой аналог Jev на T4 в Colab: ядро примерно на 70 строк, калибровку вероятностей и замер скорости. Сразу оговорюсь: это не Jev. Мы возьмём обычную instruct-модель и прочитаем решение из её логитов без дообучения, поэтому по качеству до моделей, обученных именно на решения, она не дотянет.


❯ Что мы повторяем

У Jev интерфейс простой: на вход состояние (текст) и набор именованных вопросов, на выходе вероятности. Вопросы бывают трёх типов: да/нет, выбор из вариантов и оценка по шкале. Мы сделаем то же самое: функции Bool, Choice и Score, а на выходе будет словарь с вероятностями.

❯ Как LLM «отвечает»: от токенов до логитов

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

Что происходит за один проход

  1. Токенизация. Текст режется на токены (куски слов), и каждому соответствует номер в словаре. У современных моделей в словаре от нескольких десятков тысяч до сотен тысяч токенов.

  2. Эмбеддинги. Каждый номер превращается в вектор, с которым умеют работать слои сети.

  3. Слои трансформера. Механизм внимания (attention) позволяет каждому токену «заглянуть» в предыдущие и подмешать нужную информацию. После всех слоёв у каждого токена есть своё скрытое состояние: вектор, в котором уже учтён весь контекст слева.

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

  5. Логиты. Это состояние умножается на матрицу «словарь × размерность», и получается по одному числу на каждый токен словаря. Эти числа называются логитами: чем больше логит, тем охотнее модель поставила бы этот токен следующим.

  6. Softmax. Логиты превращаются в вероятности по формуле p_i = exp(z_i) / Σ exp(z_j): все значения положительны и в сумме дают единицу.

Обычная генерация делает с этим распределением одну вещь: выбирает токен (самый вероятный или случайный), дописывает его к тексту и запускает всё заново для следующего токена. Чтобы получить JSON вида {“angry”: true, “team”: “billing”, “urgency”: 3}, это нужно повторить десятки раз: скобки, кавычки, ключи и значения тоже токены. Точное число зависит от токенизатора, но порядка двух-трёх десятков шагов, и каждый ждёт предыдущего.

Где прячется решение

Теперь самое интересное. Если в промпте написано «ответь одной буквой: A, B или C», то на первом же шаге модель и так раздаёт вероятности между токенами A, B и C. Ничего выбирать и дописывать не нужно: достаточно посмотреть на эти три числа. Мы выкидываем из словаря всё остальное и пересчитываем softmax только на буквах вариантов: p_A = exp(z_A) / (exp(z_A) + exp(z_B) + exp(z_C)).

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

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

Что значит «не галлюцинирует»

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

❯ Железо и модель

Бесплатный Colab обычно выдаёт T4 с 16 ГБ памяти (на практике доступно около 15 ГБ), сессия живёт до 12 часов, а наличие GPU никто не гарантирует: лотерея, зато бесплатная. У T4 нет аппаратной поддержки bfloat16, поэтому работаем в float16.

Берём Qwen/Qwen2.5-1.5B-Instruct: в fp16 это около 3 ГБ, и такая модель обычно нормально работает в float16. Если хочется точнее, подойдёт Qwen/Qwen2.5-3B-Instruct (около 6 ГБ). Модели, обученные в bf16, иногда выдают в fp16 NaN, поэтому в коде есть проверка логитов на конечность.

Установка: !pip install -q -U transformers accelerate scikit-learn datasets, затем Runtime → Change runtime type → T4 GPU.

❯ Шаг 1. Ядро

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
SYSTEM = ('You are a decision engine. Read the state, then answer the question '
          'with exactly one option letter and nothing else.')

def Bool(text):
    return {'type': 'bool', 'instructions': text, 'options': ['Yes', 'No']}

def Choice(text, options):
    return {'type': 'choice', 'instructions': text, 'options': list(options)}

def Score(text, lo=1, hi=5):
    vals = list(range(lo, hi + 1))
    return {'type': 'score', 'instructions': text, 'options': [str(v) for v in vals], 'values': vals}

class MiniJev:
    def __init__(self, model_id, dtype=torch.float16, device='cuda'):
        self.tok = AutoTokenizer.from_pretrained(model_id, padding_side='left')
        if self.tok.pad_token is None:
            self.tok.pad_token = self.tok.eos_token
        self.model = AutoModelForCausalLM.from_pretrained(model_id, dtype=dtype).to(device).eval()
        self.device, self._ids = device, {}

    def _label_ids(self, label):
        if label not in self._ids:
            ids = set()
            for v in (label, ' ' + label):  # 'A' и ' A', только если это один токен
                t = self.tok.encode(v, add_special_tokens=False)
                if len(t) == 1:
                    ids.add(t[0])
            self._ids[label] = sorted(ids)
        return self._ids[label]

    def _prompt(self, state, instructions, options):
        lines = 'n'.join(f'{LETTERS[i]}. {o}' for i, o in enumerate(options))
        user = (f'State:n{state}nnQuestion: {instructions}nn'
                f'Options:n{lines}nnAnswer with the letter only.')
        msgs = [{'role': 'system', 'content': SYSTEM}, {'role': 'user', 'content': user}]
        return self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)

    @torch.no_grad()
    def _option_logits(self, prompts, k):
        enc = self.tok(prompts, return_tensors='pt', padding=True,
                       add_special_tokens=False).to(self.device)
        logits = self.model(**enc).logits[:, -1, :].float()  # readout at the last position
        assert torch.isfinite(logits).all(), 'NaN/inf: try float32 or another model'
        return torch.stack([torch.stack([torch.logsumexp(logits[i, self._label_ids(LETTERS[j])], 0)
                                         for j in range(k)]) for i in range(len(prompts))])

    def logprobs(self, state, q, debias=True):
        opts, k = q['options'], len(q['options'])
        shifts = range(k) if debias else [0]
        prompts = [self._prompt(state, q['instructions'], opts[r:] + opts[:r]) for r in shifts]
        lp = torch.log_softmax(self._option_logits(prompts, k), -1)  # (shifts, k)
        out = torch.zeros(k, device=lp.device)
        for row, r in zip(lp, shifts):
            for j in range(k):
                out[(j + r) % k] += row[j] / len(shifts)
        return out  # mean log-prob of each ORIGINAL option

    def decide(self, state, questions, T=1.0, debias=True):
        res = {}
        for name, q in questions.items():
            p = torch.softmax(self.logprobs(state, q, debias) / T, -1).tolist()
            item = {'probs': dict(zip(q['options'], p))}
            if q['type'] == 'bool':
                item['p_yes'] = p[0]
            if q['type'] == 'score':
                item['value'] = sum(a * b for a, b in zip(p, q['values']))
            res[name] = item
        return res

Использование:

jev = MiniJev('Qwen/Qwen2.5-1.5B-Instruct')
state = 'I was charged twice this month, please refund one of them.'

print(jev.decide(state, {
    'angry': Bool('Is the customer angry?'),
    'team': Choice('Which team should handle it?', ['billing', 'tech support', 'sales']),
    'urgency': Score('How urgent is it?'),
}))

На выходе для каждого вопроса будет словарь с вероятностями по вариантам, плюс p_yes для да/нет и ожидаемое значение value для шкалы. 

Как это работает внутри

  1. Промпт. _prompt собирает сообщение: инструкция, состояние, вопрос и варианты A, B, C. Шаблон чата добавляет в конец «ход ассистента», так что следующий токен — это уже начало ответа.

  2. Один проход. _option_logits прогоняет весь батч промптов через модель и берёт logits[:, -1, :]: распределение для первого токена ответа, то есть последнюю позицию.

  3. Метки вариантов. Для каждой буквы берём id токенов A и  A (с пробелом): в зависимости от токенизатора ответ может начаться с любого из них. Берём только те варианты, которые кодируются одним токеном.

  4. Суммирование. Если у буквы два токена, их вероятности складываются. Делаем это через logsumexp: это log(exp(a) + exp(b)), то есть сложение вероятностей прямо в логарифмах, без потери точности.

  5. Нормировка только по вариантам. Модель может отдать часть вероятности слову вроде The. Мы это выбрасываем и нормируем только между буквами вариантов.

  6. Левый паддинг. Промпты разной длины выравниваются слева, чтобы последняя позиция у всех была настоящим концом промпта. Для моделей с RoPE сдвиг позиций из-за паддинга на результат не влияет: на крошечной случайной модели батч и одиночные запросы совпали с точностью до 3·10⁻⁸.

Борьба с позиционным смещением

Модели склонны предпочитать варианты на определённых местах списка. Параметр debias=True прогоняет все циклические сдвиги: для [‘billing’, ‘tech support’, ‘sales’] это три промпта, где буквой A по очереди оказывается каждый вариант. Затем лог-вероятности усредняются по исходным вариантам. Для да/нет это два прохода, для выбора из пяти — пять. В тесте с «подкрученной» моделью, которая бонусом любит первую позицию, без усреднения побеждал неверный вариант, а с усреднением верный.

❯ Шаг 2. Калибровка

Теперь вопрос на миллион: можно ли верить этим числам? Пока нет. Модель обучали предсказывать следующий токен, а не выдавать честные вероятности решений, поэтому 0,9 в её устах вовсе не означает «в 90% случаев я права». Типичная картина: модель слишком уверена в себе или перекошена в сторону одной метки, например «Yes».

Исправить это можно дёшево: нужно 100–300 размеченных примеров и пара строк кода.

Калибровка Платта подходит для да/нет. Берём разность лог-вероятностей z = log P(yes) − log P(no): это «уверенность» модели в логарифмическом масштабе. Затем подбираем логистическую регрессию p = σ(a·z + b) всего с двумя параметрами: a растягивает или сжимает уверенность, а b сдвигает порог.

Температурное масштабирование подходит для выбора из нескольких вариантов. У него один параметр T: вероятности считаются как softmax(s / T). При T > 1 распределение сглаживается, при T < 1 становится острее.

Качество вероятностей мерим через ECE (expected calibration error). Все предсказания раскладываются по десяти корзинам в зависимости от уверенности, и в каждой сравнивается средняя уверенность с реальной долей верных ответов. ECE — это взвешенная средняя разница. Если ECE равна 0,05, то в среднем уверенность модели отличается от реальной точности на 5 процентных пунктов.

import numpy as np
from sklearn.linear_model import LogisticRegression
from scipy.optimize import minimize_scalar

def ece(probs, y, bins=10):
    probs, y = np.asarray(probs), np.asarray(y)
    conf, ok = probs.max(1), probs.argmax(1) == y
    edges = np.linspace(0, 1, bins + 1)
    total = 0.0
    for lo, hi in zip(edges[:-1], edges[1:]):
        m = (conf > lo) & (conf <= hi)
        if m.any():
            total += m.mean() * abs(ok[m].mean() - conf[m].mean())
    return float(total)

class Platt:
    def fit(self, z, y):
        self.lr = LogisticRegression(C=1e6).fit(np.asarray(z).reshape(-1, 1), y)
        return self

    def predict(self, z):
        return self.lr.predict_proba(np.asarray(z).reshape(-1, 1))[:, 1]

def fit_temperature(S, y):  # S: (n, k) log-probs, y: (n,)
    S, y = np.asarray(S), np.asarray(y)
    def nll(log_t):
        z = S / np.exp(log_t)
        z = z - z.max(1, keepdims=True)
        lp = z - np.log(np.exp(z).sum(1, keepdims=True))
        return -lp[np.arange(len(y)), y].mean()
    return float(np.exp(minimize_scalar(nll, bounds=(-3, 3), method='bounded').x))

Код калибровки я предварительно проверил на синтетических данных, а настоящий тест ниже.

❯ Шаг 3. Измеряем на SST-2

Возьмём SST-2 из GLUE: бинарную тональность рецензий. Из 600 примеров 200 пойдут на калибровку, 400 на проверку.

from datasets import load_dataset

ds = load_dataset('glue', 'sst2', split='validation').shuffle(seed=0)
q = Bool('Is the sentiment of this movie review positive?')

def margins(rows):
    out = []
    for r in rows:
        lp = jev.logprobs(r['sentence'], q)  # [log P(yes), log P(no)]
        out.append((lp[0] - lp[1]).item())
    return np.array(out)

z = margins(ds.select(range(600)))
y = np.array(ds[:600]['label'])  # 1 = positive = 'Yes'
z_cal, y_cal, z_te, y_te = z[:200], y[:200], z[200:], y[200:]

platt = Platt().fit(z_cal, y_cal)
p_raw = 1 / (1 + np.exp(-z_te))
p_cal = platt.predict(z_te)

def report(name, p):
    print(name, 'acc=%.3f' % ((p > .5) == y_te).mean(),
          'ECE=%.3f' % ece(np.stack([1 - p, p], 1), y_te))

report('raw', p_raw)
report('calibrated', p_cal)

Прогон делался в Colab. Методика:

  • датасет: GLUE SST-2, выборка validation, перемешивание с seed=0;

  • взяты первые 600 примеров: первые 200 пошли в калибровочную выборку для Platt scaling, следующие 400 стали тестовой выборкой;

  • MiniJev вычислял разность логарифмов вероятностей Yes и No, а качество вероятностей мы оценивали до и после калибровки.

Результаты на 400 тестовых примерах:

Метрика

До калибровки

После Platt scaling

Accuracy

0,927

0,927

ECE

0,068

0,025

ECE снизилась примерно на 63%, а accuracy не изменилась. Это указывает на улучшение калибровки вероятностей без изменения точности классификации на этом тестовом наборе.

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

❯ Шаг 4. Скорость против обычной генерации JSON

Теперь проверим главное обещание: действительно ли чтение логитов быстрее генерации. Мы сравнили два способа получить те же три ответа по одному обращению (I was charged twice this month, please refund one of them.): обычную генерацию JSON и MiniJev. Замер делался серией запусков, поэтому ниже медиана, минимум и максимум.

Обычная генерация JSON вернула:

{
  "angry": true,
  "team": "billing",
  "urgency": 3
}

MiniJev вернул:

{'angry': {'probs': {'Yes': 0.9996947050094604, 'No': 0.00030534894904121757},
           'p_yes': 0.9996947050094604},
 'team': {'probs': {'billing': 0.9999345541000366,
                    'tech support': 5.19801105838269e-05,
                    'sales': 1.3419315109786112e-05}},
 'urgency': {'probs': {'1': 0.7022849917411804,
                       '2': 0.21086421608924866,
                       '3': 0.04661112278699875,
                       '4': 0.011071112006902695,
                       '5': 0.029168494045734406},
             'value': 1.4539737105369568}}

Отдел поддержки модель определила как billing с вероятностью 99,99%: редкий случай, когда нейросеть не сомневается и, кажется, права. Время:

Способ

Медиана

Минимум

Максимум

Обычная генерация JSON

1,0514 с

1,0205 с

1,3433 с

MiniJev

0,1959 с

0,1914 с

0,2338 с

По медиане MiniJev быстрее в 5,37 раза. JSON при этом валиден: Python разобрал его в {‘angry’: True, ‘team’: ‘billing’, ‘urgency’: 3}.

Откуда разница? Обычная генерация выдаёт JSON токен за токеном, и каждый следующий токен ждёт предыдущего. MiniJev делает несколько параллельных проходов по промпту и читает готовые логиты. Замер сделан на коде из этой статьи при вызове decide с параметрами по умолчанию, то есть с debias=True: на три вопроса приходится 2 + 3 + 5 = 10 вариантов порядка, прогнанных тремя батчами. MiniJev выиграл, даже не экономя.

Что здесь не доказано. Мы сравнивали скорость, а не качество ответов: обратите внимание, что обычная генерация поставила срочность 3, а MiniJev выдал распределение со средним около 1,45. Какой из ответов лучше, этот замер не показывает. Это один пример и одна серия запусков, то есть не бенчмарк. Сравнения с другими моделями и сервисами мы не измеряли.

❯ Наблюдение из эксперимента: формулировка меняет оценку

Мы проверили MiniJev на том же обращении: I was charged twice this month, please refund one of them. Отдел поддержки во всех запусках уверенно определялся как billing, а вот оценка срочности заметно зависела от формулировки вопроса.

Формулировка вопроса о срочности

Средняя оценка

Наиболее вероятный вариант

Исходный: How urgent is it?

1,40 / 5

1 — 72,74%

Вариант 1: How urgent is it to resolve this customer issue?

4,48 / 5

5 — 82,14%

Вариант 2: How urgently should a support agent handle this issue, from 1 (low) to 5 (critical)?

4,48 / 5

5 — 82,14%

Вариант 3: Assign a priority: 1=routine, 2=low, 3=normal, 4=high, 5=critical.

1,68 / 5

1 — 66,50%

На одном и том же тексте средняя оценка менялась примерно на 2,80 балла из 5. Первые два уточнённых вопроса дали одинаковое распределение, а описание шкалы через категории routine / low / normal / high / critical привело к существенно другому результату. В последнем варианте двойное списание денег получило самым вероятным ответом «routine», то есть рутину. Видимо, у клиента денег много.

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

❯ Если качества не хватает

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

  • Модель побольше. Qwen/Qwen2.5-3B-Instruct влезает в T4 без проблем.

  • Несколько примеров в промпте. Это удлиняет промпт, но может помочь модели единообразно трактовать шкалу и критерии.

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

  • Готовая открытая модель решений для сравнения, например d1-3B от Liquid AI, обученная именно на такие вопросы.

❯ Итоги

За 70 строк кода и бесплатный T4 мы заставили модель отвечать в 5,37 раза быстрее обычного JSON. Насколько срочно возвращать клиенту деньги за двойное списание, она так и не определилась: от 1,4 до 4,5 из 5, в зависимости от настроения формулировки. Зато на сомнения у неё ушло всего 0,2 секунды.

Может быть интересно:
Перейти ↩

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩

Автор: Nikuson

Источник