PunchTeammate defends goalkeeper after Tenerife striker’s deathBollywood HungamaKartik Aaryan and Kabir Khan reunite for inspirational real-life story Ladaku, set to release on June 18, 2027The Jerusalem PostWhy is the Islamic Republic banning the Lion and Sun emblem amid anti-regime protests? - analysisInquirer EntertainmentTony Butala, co-founder of The Lettermen known for ‘When I Fall in Love,’ dies at 87ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioEgypt IndependentBomb threats on board 7 flights across Europe cause disruptionsCBS NewsDemocrats win House and Senate control in sweeping victoryيورونيوزتوسيع طريق سريع رئيسي في أوزبكستان لتعزيز ممر تجاري بين الصين وأوروباRai News4 novembre, Mattarella: "Celebriamo l'Unità della Patria e il ruolo dell'Italia per la pace"Antara NewsSaudi Arabia to hold workshops to improve Hajj servicesTagesschauWo die Menschen in Deutschland am längsten lebenIl Fatto QuotidianoMidterm Usa, la stoccata di El-Sayed al vicepresidente Usa Vance: “Habibi…sarai tenuto a farmi prestare giuramento”
The Daily Newsstand · Free, Always
Wednesday, November 4, 2026

Собираем свой аналог Jev на бесплатном GPU в Colab

Translate

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}\n\nQuestion: {instructions}\n\n'
                f'Options:\n{lines}\n\nAnswer 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‑канале ↩

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.