Sakhanda Wire
NVDA — MSFT — GOOGL — META — AMZN —
← До новин

Посібник розробника з Laya: рішення zero-shot і калібрування

У цьому посібнику ми працюємо з Laya — системою прийняття рішень із відкритим кодом від Convai Innovations, яка стала одним із репозиторіїв машинного навчання з найбільшою кількістю зірок у вересні 2026 року. Laya — неавторегресивна модель System 1: замість генерування тексту енкодер із 421 мільйоном параметрів читає фрагмент тексту й набір типізованих запитань — вибір між мітками, оцінку за шкалою або відповідь «так/ні» — і повертає ймовірність для кожного варіанта за один прямий прохід, без жодного вихідного токена. Її переваги — швидкість і калібровані ймовірності, відкрита відповідь на Jev від TypeSafe. Замість повторення прикладів із README ми перевіримо ці обіцянки на реальних маркованих даних із відомими відповідями: банківському домені набору даних CLINC150 для визначення намірів. Ми виміряємо те, що насправді отримує виробничий маршрутизатор: точність у режимі zero-shot порівняно з навченим класифікатором, вплив формулювання й порядку варіантів, надійність готових імовірностей, що виправляє калібрування температурою на валідаційних даних і що воно непомітно ламає, поріг утримання, налаштований під бюджет помилок, запити поза доменом, запитання «так/ні», яке температура не може виправити, а також типізовані результати зі схеми pydantic.

Copy CodeCopiedUse a different Browser
import os
import sys
import time
import json
import warnings
import traceback
import subprocess
import urllib.request
 
RESULTS = {}
 
 
def banner(title):
    print("\n" + "=" * 78)
    print(title)
    print("=" * 78)
 
 
def section(name):
    def wrap(fn):
        def run(*a, **kw):
            banner(name)
            try:
                out = fn(*a, **kw)
                RESULTS[name] = out if isinstance(out, str) else "ok"
                return out
            except Exception as e:
                RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {e}"
                print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
                traceback.print_exc(limit=3)
                return None
        return run
    return wrap
 
 
banner("1. Install Laya and load the English checkpoint at its reviewed revision")
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "laya==0.3.27"], check=True)
 
import numpy as np
import pandas as pd
import torch
import laya
from laya.calibrate import records_from_labeled
from laya.evals import selective_accuracy, aurc
from laya.common import temp_bucket
 
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# laya.load() follows the Hub's main branch unless told otherwise. The package ships the commit
# its authors reviewed for each checkpoint; pinning it keeps this notebook's weights fixed.
REVISION = laya.PINNED_REVISIONS["convaiinnovations/laya"]
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    agent = laya.load("convaiinnovations/laya", device=DEVICE, revision=REVISION)
# On CUDA Laya autocasts to fp16/bf16. Turning that off keeps every device in fp32, so a GPU run
# reproduces the CPU numbers printed below to within floating-point noise.
agent.amp_enabled = False
SHIPPED = (list(agent.temperature), dict(agent.temperature_by_options))
 
n_params = sum(p.numel() for p in agent.model.parameters())
print(f"  laya {laya.__version__}  |  torch {torch.__version__}  |  device {DEVICE}, fp32")
print(f"  checkpoint convaiinnovations/laya @ {REVISION[:7]}  |  {n_params / 1e6:.0f}M parameters"
      f"  |  max_len {agent.cfg['max_len']}, head_max_len {agent.cfg['head_max_len']}")
print("\n  Temperatures shipped with the checkpoint (probabilities = softmax(logits / T)):")
for qt, name in enumerate(["choice", "score", "noul"]):
    print(f"    {name:7s} type-level T = {SHIPPED[0][qt]:.3f}")
for bucket, t in sorted(SHIPPED[1].items()):
    print(f"    {bucket:12s} T = {t:.3f}")
for w in caught:
    if "temperature" in str(w.message):
        print("\n  Warning at load time:\n    " + str(w.message).replace("; ", ";\n    "))
print("\n  T > 1 softens probabilities and T < 1 sharpens them. Remember the choice:11+ row: the")
print("  checkpoint ships 0.10 there, which the loader clamps to 0.5, so any choice question with")
print("  11 or more options gets probabilities SHARPENED by 2x. Step 6 measures what that costs.")

Ми встановлюємо випущений пакет laya 0.3.27 і завантажуємо англомовний чекпойнт. Два рішення забезпечують відтворюваність запуску. За замовчуванням laya.load використовує основну гілку Hugging Face, тому ми фіксуємо ревізію, перевірену авторами бібліотеки; вона доступна через laya.PINNED_REVISIONS. А на CUDA Laya автоматично використовує обчислення у зниженій точності, тож ми вимикаємо це, щоб усі пристрої працювали у fp32 і запуск на GPU відтворював наведені тут результати CPU. Виведення температур, установлених у чекпойнті, дає перший висновок ще до будь-якого прогнозування: значення для запитань із одинадцятьма або більше варіантами дорівнює 0.10, що виходить за межі допустимого діапазону, тому завантажувач замінює його на 0.5 і видає попередження. Температура нижче одиниці загострює ймовірності, тож кожна відповідь на запитання з такою кількістю варіантів виглядатиме вдвічі впевненішою, ніж є насправді для необробленої моделі.

Copy CodeCopiedUse a different Browser
TICKET = "Hi, we were billed twice for March. Please refund the duplicate today or we will cancel our plan."
TRIAGE = {
    "department": {"type": "choice", "instructions": "Which department should handle this?",
                   "criteria": {"billing": "invoices, payments, refunds",
                                "technical": "bugs, outages, system errors",
                                "other": "everything else"}},
    "urgency": {"type": "score", "instructions": "How urgent is this?",
                "criteria": ["not urgent", "soon", "blocking"]},
    "churn_risk": {"type": "noul", "instructions": "Does the user threaten to cancel or leave?"},
}
 
 
@section("2. One forward pass, three typed questions, zero output tokens")
def first_decision():
    r = agent.predict(TICKET, TRIAGE)
    a = r["answers"]
    d, u, c = a["department"], a["urgency"], a["churn_risk"]
    print(f"  state: {TICKET!r}\n")
    print(f"  department (choice)  -> {d['choice']!r}   probabilities {d['probabilities']}")
    print(f"  urgency    (score)   -> {u['score']:.2f} on 0..2   level probabilities {u['probabilities']}")
    print(f"  churn_risk (noul)    -> P(yes) = {c['noul']:.3f}")
    print("\n  Two confidence fields, two different quantities:")
    for qid, ans in a.items():
        print(f"    {qid:11s} answer_confidence {ans['answer_confidence']:.3f}   confidence {ans['confidence']:.3f}")
    print("  answer_confidence is the probability of the reported answer, max(p): the number that")
    print("  calibration, the abstention gate and every metric below use. confidence is 1 - normalised")
    print("  entropy, whose scale depends on the number of options. Do not threshold on it.")
    print(f"\n  usage: {r['usage']}")
    print("  output_tokens is always 0: Laya scores the options it is given and never generates text.")
    return f"{d['choice']} / urgency {u['score']:.2f} / P(churn) {c['noul']:.2f} in one pass"
 
 
first_decision()

Один виклик predict відповідає на три типізовані запитання про заявку до служби підтримки за один прямий прохід: визначає відділ, терміновість за шкалою від 0 до 2 і ризик відтоку у форматі «так/ні». Результат містить імовірність для кожного варіанта й два поля впевненості, які легко сплутати. answer_confidence — це ймовірність повідомленої відповіді; саме її використовують калібрування, поріг утримання та всі метрики далі в цьому посібнику. confidence — це одиниця мінус нормалізована ентропія, масштаб якої залежить від кількості варіантів у запитанні. Блок usage показує нуль вихідних токенів, оскільки Laya оцінює надані їй варіанти й ніколи не генерує текст.

Copy CodeCopiedUse a different Browser
@section("3. What a pass costs: questions are rows, options are nearly free")
def cost_model():
    def median_ms(q, n=7):
        agent.predict(TICKET, q)
        times = []
        for _ in range(n):
            t0 = time.perf_counter()
            r = agent.predict(TICKET, q)
            times.append(1000 * (time.perf_counter() - t0))
        return float(np.median(times)), r["usage"]["input_tokens"]
 
    print(f"  {'one state, asking ...':34s} {'ms (median of 7)':>16s} {'input_tokens':>13s}")
    rows = {}
    for n in (1, 4, 16):
        q = {f"q{i}": {"type": "noul", "instructions": f"Does the message mention topic number {i}?"} for i in range(n)}
        rows[f"{n} yes/no"] = median_ms(q)
        print(f"  {f'{n:2d} yes/no questions':34s} {rows[f'{n} yes/no'][0]:16.1f} {rows[f'{n} yes/no'][1]:13d}")
    for k in (3, 15, 40):
        q = {"pick": {"type": "choice", "instructions": "Pick one", "criteria": [f"option {i}" for i in range(k)]}}
        rows[f"{k} options"] = median_ms(q)
        print(f"  {f'1 choice question, {k:2d} options':34s} {rows[f'{k} options'][0]:16.1f} {rows[f'{k} options'][1]:13d}")
    print("\n  Every question is encoded as its own (state, question) row, so 16 yes/no questions cost")
    print("  roughly 16 rows. All options of one choice question share a single row and its head")
    print("  budget, so 40 options cost far less than 40 yes/no questions. Design rule: ask one")
    print("  choice question with many options, not many yes/no questions.")
    return (f"16 yes/no {rows['16 yes/no'][0]:.0f} ms vs one 40-option choice "
            f"{rows['40 options'][0]:.0f} ms")
 
 
cost_model()

Перш ніж будувати рішення на Laya, ми вимірюємо вартість прямого проходу для одного повідомлення. Кожне запитання стає окремим рядком у парі з повідомленням, тому шістнадцять запитань «так/ні» займають приблизно у вісім разів більше часу, ніж одне. Усі варіанти запитання з вибором використовують один рядок і бюджет його голови, тому запитання із сорока варіантами на нашому CPU коштує ледве вдвічі дорожче за запитання з трьома варіантами й приблизно чверть вартості шістнадцяти запитань «так/ні». Звідси випливає правило проєктування, яке визначає все подальше: ставте одне запитання з вибором і великою кількістю варіантів, а не багато запитань «так/ні».

Copy CodeCopiedUse a different Browser
CARD = json.load(urllib.request.urlopen("https://huggingface.co/api/datasets/clinc/clinc_oos"))["cardData"]
PLUS = next(c for c in CARD["dataset_info"] if c["config_name"] == "plus")
NAMES = {int(k): v for k, v in PLUS["features"][1]["dtype"]["class_label"]["names"].items()}
 
 
def clinc(split):
    df = pd.read_parquet(f"https://huggingface.co/api/datasets/clinc/clinc_oos/parquet/plus/{split}/0.parquet")
    return df.assign(label=df.intent.map(NAMES))
 
 
# The banking domain of CLINC150 (15 intents), with the one-line descriptions a developer would write.
DESCRIBED = {
    "balance": "checking how much money is in an account",
    "transactions": "looking up recent transactions on an account",
    "transfer": "moving money between accounts or to another person",
    "freeze_account": "freezing or locking an account",
    "account_blocked": "an account that is blocked or locked and cannot be used",
    "pay_bill": "paying a bill",
    "bill_balance": "how much is owed on a bill",
    "bill_due": "when a bill is due",
    "interest_rate": "the interest rate on an account",
    "min_payment": "the minimum payment that is due",
    "order_checks": "ordering new checks or a checkbook",
    "pin_change": "changing a PIN",
    "report_fraud": "reporting fraud or suspicious activity",
    "routing": "the bank routing number",
    "spending_history": "how much was spent over a period or on a category",
}
INTENTS = list(DESCRIBED)
ASK = "Which banking request is this?"
 
 
def route(states, criteria, **kw):
    """One choice question over `criteria` for every state; returns choices, confidences, results."""
    res = agent.predict_batch(list(states), {"intent": {"type": "choice", "instructions": ASK, "criteria": criteria}},
                              batch_size=32, **kw)
    return (np.array([r["answers"]["intent"]["choice"] for r in res]),
            np.array([r["answers"]["intent"]["answer_confidence"] for r in res]), res)

Для роботи з реальними маркованими даними ми використовуємо CLINC150 — загальнодоступний бенчмарк класифікації намірів зі 150 намірами у десяти доменах і набором запитів поза доменом, який ми безпосередньо зчитуємо з Hugging Face Hub у форматі parquet. Ми беремо його банківський домен: п’ятнадцять намірів, по 100 навчальних, 20 валідаційних і 30 тестових запитів для кожного, — і просимо Laya маршрутизувати 450 тестових запитів у режимі zero-shot, надаючи назву кожного наміру та однорядковий опис, який міг би написати розробник. Точність без жодного маркованого прикладу становить 0.804. Для порівняння, класифікатор TF-IDF із логістичною регресією досягає 0.651 із трьома маркованими запитами на намір, 0.848 із десятьма та 0.904 із тридцятьма.

Далі ми змінюємо лише формулювання варіантів. Якщо передати Laya п’ятнадцять простих назв намірів без наших описів, точність зростає з 0.804 до 0.878, а час роботи скорочується вдвічі, оскільки варіанти займають менше половини токенів. Описи розмивали відмінності, які назви зберігали: account_blocked десять разів спрямовувався до freeze_account, а запити про відсоткову ставку — до balance. Зворотний порядок простих назв змінює 4.2% окремих відповідей, хоча загальна точність майже не змінюється. Це свідчить про позиційне упередження, тому порядок варіантів у виробничій системі має збігатися з порядком, який ви протестували. Лише марковані дані могли показати обидва ефекти; надалі ми маршрутизуємо за простими назвами.

Потім ми з’ясовуємо, наскільки надійними є ймовірності. Запитання з п’ятнадцятьма варіантами потрапляє до температурного сегмента choice:11+ чекпойнта — того самого, для якого значення було обмежено до 0.5. Таблиця надійності для 450 тестових запитів показує результат: 92% відповідей заявляють упевненість не менш як 0.9, але правильними є 91.1% із них; середня впевненість 0.974 значно перевищує точність 0.878, а очікувана похибка калібрування становить 0.102. Навчальна ціль Laya використовує правильні правила оцінювання, що і має на увазі картка моделі під словом «калібрована». Однак калібрування є властивістю конкретного запитання на конкретному розподілі, тому його потрібно вимірювати на власних маркованих даних.

Модуль калібрування Laya перетворює марковані приклади на записи з необробленими логітами та цільовими значеннями й підбирає для них температуру. Ми створюємо 300 записів із валідаційної частини та викликаємо agent.fit_temperatures, який підбирає температуру вибору 1.258 і встановлює її, після чого виводить повну таблицю температур поруч із початковими значеннями. Підгонка замінила всю карту: усі записи для окремих кількостей варіантів зникли, оскільки сегменту потрібно 2 000 записів, щоб зберегти власну температуру; температури для оцінки та «так/ні» скинулися до 1.0, бо для цих типів не було записів. Отже, одна підгонка для запитань із вибором непомітно змінила калібрування кожного запитання «так/ні» в агенті. Тому ми відновлюємо початкові значення й установлюємо лише виміряний сегмент. На тестовому наборі похибка калібрування зменшується з 0.102 до 0.059, а точність не змінюється, оскільки температура не змінює варіант, що має найбільший логіт. save_calibration записує результат у JSON-файл, який laya.load може завантажити.

laya.fit_abstention_thresholds використовує ті самі валідаційні записи й для кожного сегмента за кількістю варіантів повертає найменший поріг упевненості, за якого помилка на валідації не перевищує задану ціль. Для цілі 5% він обирає 0.602: це залишає 95.7% валідаційних запитів із похибкою 4.5%. Laya застосовує той самий поріг самостійно, якщо передати його до predict_batch як min_confidence, позначаючи 35 із 450 тестових відповідей як утримані. На тестовому наборі, однак, цей поріг залишає 92.2% запитів із похибкою 9.2% — майже вдвічі більшою за бюджет; для цілі 2% фактична похибка становить 5.3%. Причина зрозуміла: на валідації Laya має рацію у 92.7% запитів, а на тесті — лише у 87.8%. Отже, бюджет помилок, підібраний на одному зразку, працює лише для трафіку, схожого на нього. Саме ранжування надійне: половина тестових відповідей із найвищою впевненістю правильна у 97.8% випадків. Але ціль помилки потребує запасу й регулярного переналаштування на реальному трафіку. Пороги залишаються окремими для кожного сегмента кількості варіантів, оскільки одне число не можна перенести між запитаннями з двома та п’ятнадцятьма варіантами.

Виробничий трафік містить запити, для яких маршрутизатор не створювався, тому ми додаємо 150 запитів CLINC поза доменом і 150 запитів з інших доменів CLINC. Калібрована впевненість чітко їх розділяє: середня впевненість для банківських запитів становить 0.912, для інших — близько 0.25. Поріг у 5% зупиняє 89.3% запитів з інших доменів і 93.3% запитів поза доменом, утримуючи 7.8% банківських запитів. Альтернативний підхід не потребує маркованих даних: шістнадцятий варіант «не банківський запит» виявляє 80.0% і 90.0% таких запитів відповідно, але ціною відведення 2.0% банківських запитів до цього варіанта та зниження банківської точності з 0.878 до 0.864, оскільки новий варіант змінює оцінювання кожного наміру.

Окреме запитання «так/ні» здається природним способом перевірки належності до домену, тому ми запитуємо, чи стосується кожне повідомлення банківського рахунку користувача, рахунків або платежів. Воно добре ранжує приклади, AUROC становить 0.945, але має упередження в бік відповіді «ні»: середня ймовірність для банківських запитів дорівнює лише 0.361, тому за стандартного порога 0.5 воно розпізнає лише 28.9% із них. Підгонка температури на 550 валідаційних відповідях досягає верхньої межі 5.0 і нічого не змінює за цього порога, оскільки ділення двох логітів на будь-яку температуру не змінює того, який із них більший. Масштабування температурою виправляє масштаб, а не зміщення. Потрібно трактувати ймовірність як оцінку й вибрати її поріг на маркованих даних: поріг 0.09, визначений на валідаційній частині, дає на тестовому наборі повноту 92.0% і специфічність 83.0%.

Нарешті, ми підключаємо це до коду застосунку через схему pydantic. laya.decide_batch перетворює поле Literal на запитання з вибором між простими назвами, що, як показав крок 5, краще працює в цьому випадку, а bool — на запитання «так/ні», після чого повертає відповіді у вигляді екземпляра моделі з перевіркою. Передавання порога з кроку 8 як min_confidence перетворює невпевнений намір на None, що приймається полем Optional; отже, None стає явним сигналом «запитати людину». Два запити поза доменом повертаються з intent=None. Однак для булевого поля всередині decide використовується фіксований поріг 0.5, тому повідомлення про шахрайство позначається як in_scope=False за ймовірності 0.29. Потрібно прочитати ймовірність із деталей результату й застосувати поріг із кроку 10.

Підсумок виводить однорядковий результат кожного кроку та практики, які варто перенести у власні проєкти: зафіксувати чекпойнт і перевірити його початкові температури, протестувати формулювання критеріїв і порядок варіантів на маркованих даних, підбирати температури на відкладених даних і встановлювати лише виміряний сегмент, застосовувати поріг утримання для кожного сегмента кількості варіантів із запасом, а також вибирати поріг для запитань «так/ні» на маркованих даних. Наприкінці зазначено подальші напрямки: донавчання, яке доступне в laya.train в основній гілці репозиторію, але ще не входить до колеса 0.3.27, багатомовний чекпойнт за laya.Router і серверне використання.

Підсумовуючи, Laya виконує більшість своїх обіцянок: за один прямий прохід відповідає на кілька типізованих запитань без генерування токенів; маршрутизатор із простими назвами досяг точності 0.878 на п’ятнадцяти реальних банківських намірах без навчальних даних, тоді як класифікатору TF-IDF потрібно від десяти до тридцяти маркованих прикладів на намір, щоб зрівнятися з цим результатом; а його калібрована впевненість достатньо добре відокремила запити в домені від запитів поза ним, щоб зупинити понад дев’ять із десяти останніх. Однак цінність імовірностей залежить від роботи, яку бібліотека залишає користувачеві, а деякі її значення за замовчуванням працюють у неправильному напрямку. Початкова температура для запитань із одинадцятьма або більше варіантами загострює, а не пом’якшує ймовірності; один виклик калібрування стирає температури для типів запитань, яких він не бачив; бюджет помилок, підібраний на валідаційних даних, працював лише для схожого трафіку; а запитання «так/ні» може добре ранжувати приклади, але опинитися не з того боку порога 0.5 — і жодна температура цього не виправить, тоді як проєкція схеми жорстко закріплює таке рішення. Для кожної проблеми вище наведено виправлення на кілька рядків, і без нього кожна з них пройшла б непомітно. Практичний висновок такий самий, як у картці моделі цієї бібліотеки, але її налаштування за замовчуванням змушують про нього забувати: калібрована модель прийняття рішень — це модель, яку ви відкалібрували на власних мітках для власних запитань.


Перегляньте ПОВНИЙ КОД тут. Уся подяка досліднику цього проєкту. Також підписуйтеся на нас у Twitter і не забудьте приєднатися до нашого ML SubReddit із понад 150 тисячами учасників та підписатися на нашу розсилку. Стривайте! Ви є в Telegram? Тепер ви також можете приєднатися до нас у Telegram.

[Реклама] Веб — це єдиний API, якого бракує більшості агентів. Бази даних, календарі та репозиторії мають API. Відкритий веб здебільшого — ні. MCP-сервер TinyFish надає будь-якому MCP-клієнту чотири інструменти: TinySearch, TinyFetch (повні сторінки у форматі Markdown, включно з JavaScript), TinyBrowser для входу в облікові записи й роботи з формами та TinyAgent для багатокрокових завдань. Search і Fetch безкоштовні.

Перекладено автоматично з англійської. Оригінал статті — за посиланням нижче.

Вперше опубліковано виданням MarkTechPost

Читати оригінал на MarkTechPost ↗

Текст і зображення належать MarkTechPost і наводяться тут із зазначенням авторства та посиланням на оригінальну публікацію.

← До новин

Ще новини

Усі останні новини