Как устроена decision-модель Clef: разбор модели принятия решений. Clef.. Clef. desicion-model.

На фоне популярности decision-моделей мне захотелось познакомиться с их механизмом работы. Объектом исследования я выбрал модель Clef от Cloudflare просто потому, что она больше всего на слуху.

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

Приступим!

Общая схема и обозначения

Как устроена decision-модель Clef: разбор модели принятия решений - 1

Символ

Значение

N

число токенов в последовательности

d

размер скрытого состояния Qwen

w

ширина головы

K

число вопросов

n_k

число опций у вопроса k

M=sum_k n_k

всего опций

S_k=[s_k,e_k)

спан инструкции вопроса k

O_{kj}=[a_{kj},b_{kj})

спан опции j вопроса k

tilde H

выход Qwen после LayerNorm головы, [Ntimes d]

Einmathbb{R}^{Vtimes d}

матрица lm_head Qwen

x_t

номер токена в позиции t (input_ids[t])

Вход: запрос API

Запрос состоит из state (о чём решаем) и questions (что нужно решить). Пример от Cloudflare:

{
  "model": "clef",
  "state": "Checkout has been failing for every customer for the last hour.",
  "questions": {
    "urgent": { "type": "noul", "instructions": "Is this support request urgent?" },
    "team": {
      "type": "choice",
      "instructions": "Which team should handle this request?",
      "criteria": {
        "billing": "Payments, invoices, and refunds",
        "technical": "Outages, errors, and configuration",
        "sales": "Plans and upgrades"
      }
    },
    "severity": {
      "type": "score",
      "instructions": "How severe is the customer impact?",
      "criteria": ["No impact", "Minor", "Major", "Critical"]
    }
  }
}

Типы вопросов

Тип

Смысл

Откуда берутся опции

Опций в примере

noul

да / нет

создаются автоматически: true, false

2

choice

один вариант из списка

ключи criteria, отсортированные по алфавиту

3

score

уровень на шкале

индексы списка criteria: "0", "1", …

4

Как вопрос превращается в список опций

Код из joint_schema_model.py:

QUESTION_TYPES = {"noul": 0, "choice": 1, "score": 2}

def question_options(question: dict[str, Any]) -> list[tuple[str, Any]]:
    question_type = str(question["type"])
    if question_type == "noul":
        criteria = {
            "true": "The proposition is true or the answer is yes.",
            "false": "The proposition is false or the answer is no.",
        }
        criteria.update(question.get("criteria") or {})
        return [(key, criteria[key]) for key in ("true", "false")]
    if question_type == "choice":
        return sorted((str(key), value) for key, value in question["criteria"].items())
    return [(str(index), value) for index, value in enumerate(question["criteria"])]

Для team получается отсортированный по ID список:

[("billing",   "Payments, invoices, and refunds"),
 ("sales",     "Plans and upgrades"),
 ("technical", "Outages, errors, and configuration")]

Этап 1. encode_record: запрос становится текстом

Как устроена decision-модель Clef: разбор модели принятия решений - 19

encode_record склеивает все поля JSON в один промпт и попутно запоминает, где в нём лежит каждый вопрос и каждая опция.

Это происходит в три шага.

Шаг 1. question_options превращает criteria в список пар (раздел 2).

Шаг 2. Каждая пара заворачивается в маленький словарь:

semantics = {"option_id": option_id}
if description is not None:
    semantics["description"] = description

Шаг 3. Словарь превращается в строку функцией render:

def render(value: Any) -> str:
    if isinstance(value, str):
        return value
    return json.dumps(
        value,
        ensure_ascii=False,
        separators=(",", ":"),
        sort_keys=True,
    )

Получается:

{"description":"Payments, invoices, and refunds","option_id":"billing"}

Фиксированный порядок ключей и отсутствие пробелов нужны, чтобы одна и та же опция всегда давала одни и те же токены.

Промпт целиком ([префикс], [state] и т.д добавлены для наглядности):

[префикс]  <|im_start|>system
           Read the complete state and schema. Decide every field jointly. ...<|im_end|>
           <|im_start|>user
           STATE:
[state]    Checkout has been failing for every customer for the last hour.
[схема]
           SCHEMA FIELDS:

           FIELD 1
           ID: urgent
           TYPE: noul
           INSTRUCTION: Is this support request urgent?                    ← question_span
           ALLOWED OPTIONS:
           OPTION 1: {"description":"The proposition is true or the answer is yes.","option_id":"true"}   ← option_span
           OPTION 2: {"description":"The proposition is false or the answer is no.","option_id":"false"} ← option_span
           END FIELD

           FIELD 2
           ID: team
           TYPE: choice
           INSTRUCTION: Which team should handle this request?             ← question_span
           ALLOWED OPTIONS:
           OPTION 1: {"description":"Payments, invoices, and refunds","option_id":"billing"}
           OPTION 2: {"description":"Plans and upgrades","option_id":"sales"}
           OPTION 3: {"description":"Outages, errors, and configuration","option_id":"technical"}
           END FIELD

           FIELD 3
           ID: severity
           TYPE: score
           INSTRUCTION: How severe is the customer impact?                 ← question_span
           ALLOWED OPTIONS:
           OPTION 1: {"description":"No impact","option_id":"0"}
           OPTION 2: {"description":"Minor","option_id":"1"}
           OPTION 3: {"description":"Major","option_id":"2"}
           OPTION 4: {"description":"Critical","option_id":"3"}
           END FIELD
[суффикс]  <|im_end|>
           <|im_start|>assistant
           <think>

           </think>

           JOINT SCHEMA DECISIONS:
Код

После токенизации алгоритм запоминает позиции в промпте вопросов и опций:

def encode_record(
    tokenizer: Any,
    record: dict[str, Any],
    max_length: int = 16384,
    max_state_tokens: int | None = None,
    processor: Any | None = None,
) -> EncodedRecord:
    schema_ids = _tokens(tokenizer, "nnSCHEMA FIELDS:n")
    questions: list[EncodedQuestion] = []
    for question_index, (question_id, question) in enumerate(record["questions"].items()):
        schema_ids.extend(
            _tokens(
                tokenizer,
                f"nFIELD {question_index + 1}nID: {question_id}nTYPE: {question['type']}nINSTRUCTION: ",
            )
        )
        question_start = len(schema_ids)
        instructions = question.get("instructions")
        if instructions is None or instructions == "":
            instructions = str(question_id)
        schema_ids.extend(_tokens(tokenizer, render(instructions)))
        question_end = len(schema_ids)
        schema_ids.extend(_tokens(tokenizer, "nALLOWED OPTIONS:n"))

        option_spans: list[tuple[int, int]] = []
        option_ids: list[str] = []
        for option_index, (option_id, description) in enumerate(question_options(question)):
            schema_ids.extend(_tokens(tokenizer, f"OPTION {option_index + 1}: "))
            option_start = len(schema_ids)
            semantics = {"option_id": option_id}
            if description is not None:
                semantics["description"] = description
            schema_ids.extend(_tokens(tokenizer, render(semantics)))
            option_spans.append((option_start, len(schema_ids)))
            option_ids.append(option_id)
            schema_ids.extend(_tokens(tokenizer, "n"))
        schema_ids.extend(_tokens(tokenizer, "END FIELDn"))

Каждый кусок токенизируется отдельно для того, чтобы не происходило склеивание (если токенизировать весь промпт целиком, BPE может склеить конец "OPTION 1: " с началом JSON в о10. дин токен)

После этого происходит сдвиг всей схемы на длину префикса и state:

    schema_offset = len(prefix_ids) + len(state_ids)
    shifted_questions = tuple(
        EncodedQuestion(
            question_id=question.question_id,
            question_type=question.question_type,
            question_span=(
                question.question_span[0] + schema_offset,
                question.question_span[1] + schema_offset,
            ),
            option_spans=tuple(
                (start + schema_offset, end + schema_offset)
                for start, end in question.option_spans
            ),
            option_ids=question.option_ids,
        )
        for question in questions
    )
    input_ids = tuple(prefix_ids + state_ids + schema_ids + suffix_ids)

Выход этапа

Выход

Размер

input_ids

N

question_span

K=3 пары чисел

option_spans

M=9 пар чисел, сгруппированы по вопросам

option_ids, question_type

метаданные для сборки ответа

Этап 2. Qwen: один проход

Как устроена decision-модель Clef: разбор модели принятия решений - 23

На этом этапе происходит расчет по всем слоям без головы и применение LayerNorm к последнему выходному слою:

H=mathrm{Qwen}(x_1,dots,x_N)inmathbb{R}^{Ntimes d},qquad tilde H=mathrm{LN}_h(H)

Выход этапа

last_hidden_state [Btimes Ntimes5120] — по вектору на каждый токен каждой записи батча.

Этап A. Извлечение векторов

Как устроена decision-модель Clef: разбор модели принятия решений - 26

После этапа 2 есть таблица tilde H. Номер строки совпадает с номером токена в input_ids. Этап A вырезает из этой таблицы нужные строки (спаны, которые были выделены ранее).

строка   токен            вектор
  0      <|im_start|>     [ ... ]
 ...
 152     Which            [ ... ]  ┐
 ...                               ├ question_span team = (152, 160)
 159     ?                [ ... ]  ┘
 ...
 164     {"               [ ... ]  ┐
 165     description      [ ... ]  │
 ...                               ├ option_span billing = (164, 184)
 183     "}               [ ... ]  ┘
 ...
 319     :                [ ... ]  ← последний токен
Код
    def forward(
        self,
        hidden_states: torch.Tensor,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor,
        records: list[EncodedRecord],
        output_embedding_weight: torch.Tensor,
    ) -> list[list[torch.Tensor]]:
        results: list[list[torch.Tensor]] = []
        normalized_hidden = self.hidden_norm(hidden_states)
        for batch_index, record in enumerate(records):
            sequence_length = int(attention_mask[batch_index].sum().item())
            sequence_hidden = normalized_hidden[batch_index, :sequence_length]
            memory = self.memory_projection(sequence_hidden).unsqueeze(0)
            global_vector = sequence_hidden[-1]
            question_vectors = torch.stack(
                [
                    self._mean_span(sequence_hidden, question.question_span)
                    for question in record.questions
                ]
            )
            type_ids = torch.tensor(
                [question.question_type for question in record.questions],
                device=hidden_states.device,
            )
            option_contexts: list[torch.Tensor] = []
            lexical_options: list[torch.Tensor] = []
            option_counts = []
            for question in record.questions:
                context_vectors = torch.stack(
                    [
                        self._mean_span(sequence_hidden, span)
                        for span in question.option_spans
                    ]
                )
                lexical_vectors = []
                for start, end in question.option_spans:
                    token_ids = input_ids[batch_index, start:end]
                    lexical_vectors.append(output_embedding_weight[token_ids].mean(dim=0))
                lexical = torch.stack(lexical_vectors)
                option_contexts.append(context_vectors)
                lexical_options.append(lexical)
                option_counts.append(len(question.option_spans))
```

Сама операция среднего по спану:

    @staticmethod
    def _mean_span(values: torch.Tensor, span: tuple[int, int]) -> torch.Tensor:
        start, end = span
        return values[start:end].mean(dim=0)

В итоге имеем следующие сущности:

Выход

Какие строки берутся

Размер

Смысл

memory mathcal M

tilde Hумноженная на W_{text{mem}}

[Ntimes1024]

«база улик», весь текст

g

одна строка L-1

[5120]

сжатое «всё прочитанное»

q_k

строки инструкции вопроса

Ktimes[5120]

смысл каждого вопроса

c_{kj} (ctx)

строки опции в tilde H

Mtimes[5120]

опция в контексте

ell_{kj} (lex)

те же позиции, но через input_ids в таблице lm_head

Mtimes[5120]

словарный смысл слов опции

Немного формул:

mathcal M=tilde HW_{text{mem}},qquad g=tilde H_{L-1}

q_k=frac{1}{|S_k|} sum_{t in S_k} tilde{H}_t, qquad c_{kj}=frac{1}{|O_{kj}|} sum_{t in O_{kj}} tilde{H}_t, qquad ell_{kj}=frac{1}{|O_{kj}|} sum_{t in O_{kj}} E x_t

Пример:

Вектор вопроса. Пусть question_span team = (5, 8), то есть строки 5, 6, 7:

строка 5  "Which"   [ 0.2,  1.0, -0.4,  0.6 ]
строка 6  "team"    [ 0.8,  0.6,  0.0,  0.2 ]
строка 7  "?"       [-0.1,  0.2,  0.4,  1.0 ]
                    ───────────────────────── среднее по столбцам
q_team            = [ 0.3,  0.6,  0.0,  0.6 ]

Каждая координата – среднее трёх чисел в столбце: например, (0.2+0.8-0.1)/3=0.3.

Контекстный вектор опции. Пусть option_span billing = (9, 11), то есть строки 9 и 10:

строка  9  "billing"    [ 1.2, -0.2,  0.4,  0.0 ]
строка 10  "payments"   [ 0.6,  0.4,  0.2,  0.8 ]
                        ─────────────────────────
ctx_billing           = [ 0.9,  0.1,  0.3,  0.4 ]

Лексический вектор опции. Позиции те же (9, 11), но строки берутся не из tilde H. Сначала по спану читаются номера токенов, потом по этим номерам берутся строки lm_head (номера условные):

input_ids[9]  = 3729    →  E[3729]  = [ 0.05, -0.02,  0.10,  0.01 ]
input_ids[10] = 14310   →  E[14310] = [ 0.03,  0.04,  0.02, -0.01 ]
                                       ───────────────────────────
lex_billing                          = [ 0.04,  0.01,  0.06,  0.00 ]

Разница между ctx и lex: строка 9 в tilde H– это слово «billing», прочитанное Qwen после тикета, и она меняется от запроса к запросу. Строка E_{3729} – слово «billing» само по себе, и она всегда одна и та же.

g и memory. g – просто последняя строка таблицы. memory – все строки, каждая умноженная на W_{text{mem}}. Спаны тут не нужны.

Выход этапа

Выход

Размер

Как хранится

memory

[Ntimes1024]

одна матрица

g

[5120]

один вектор

q

Ktimes[5120]

по вектору на вопрос, в порядке вопросов

ctx

Mtimes[5120]

списки по вопросам: 2, 3, 4

lex

Mtimes[5120]

списки по вопросам: 2, 3, 4

option_counts

[2, 3, 4]

сколько опций у каждого вопроса

type_ids

[0, 1, 2]

тип каждого вопроса

Прежде чем переходить к следующему этапу распишу какие проекции мы имеем:

self.hidden_norm = torch.nn.LayerNorm(hidden_size)
self.memory_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.question_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.option_question_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.global_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.option_context_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.option_lexical_projection = torch.nn.Linear(hidden_size, width, bias=False)
self.type_embedding = torch.nn.Embedding(3, width)

Матрица

Слой в коде

Вход

W_{text{mem}}

memory_projection

все токены tilde H [Ntimes5120]

W_c

option_context_projection

ctx каждой опции [Mtimes5120]

W_ell

option_lexical_projection

lex каждой опции [Mtimes5120]

W_{oq}

option_question_projection

q вопроса, которому принадлежит опция

W_q

question_projection

q каждого вопроса [Ktimes5120]

W_g

global_projection

g [5120]

T

type_embedding

тип вопроса (0, 1, 2)

Этап B. Evidence routing ×2

Как устроена decision-модель Clef: разбор модели принятия решений - 73

Evidence routing – этап, в котором каждая опция сама ищет в тексте подтверждения для себя. Это два одинаковых по устройству слоя EvidenceRoutingLayer, каждый со своими весами.

Сам Слой
class EvidenceRoutingLayer(torch.nn.Module):
    def __init__(
        self,
        width: int,
        heads: int,
        feedforward: int,
        dropout: float = 0.0,
    ) -> None:
        super().__init__()
        self.query_norm = torch.nn.LayerNorm(width)
        self.memory_norm = torch.nn.LayerNorm(width)
        self.attention = torch.nn.MultiheadAttention(
            width,
            heads,
            dropout=dropout,
            batch_first=True,
        )
        self.attention_dropout = torch.nn.Dropout(dropout)
        self.feedforward_norm = torch.nn.LayerNorm(width)
        self.feedforward = torch.nn.Sequential(
            torch.nn.Linear(width, feedforward),
            torch.nn.GELU(),
            torch.nn.Dropout(dropout),
            torch.nn.Linear(feedforward, width),
            torch.nn.Dropout(dropout),
        )

    def forward(self, queries: torch.Tensor, memory: torch.Tensor) -> torch.Tensor:
        normalized_queries = self.query_norm(queries)
        routed, _ = self.attention(
            normalized_queries,
            self.memory_norm(memory),
            self.memory_norm(memory),
            need_weights=False,
        )
        queries = queries + self.attention_dropout(routed)
        return queries + self.feedforward(self.feedforward_norm(queries))

Каждая опция обрабатывается независимо и на выходе получаем распределение «важности» токена всего запроса к конкретной опции.

Вход

u^{(0)}_{kj}=W_c c_{kj}+W_ellell_{kj}+W_{oq}q_k

Для вопроса team:
u_billing   = W_c·ctx_billing   + W_ℓ·lex_billing   + W_oq·q_team
u_sales     = W_c·ctx_sales     + W_ℓ·lex_sales     + W_oq·q_team
u_technical = W_c·ctx_technical + W_ℓ·lex_technical + W_oq·q_team

Что даёт каждое слагаемое:

  • W_c,c – что Qwen понял об опции после прочтения тикета;

  • W_ell,ell – что значат слова опции сами по себе;

  • W_{oq},q – к какому вопросу опция относится. Опция ищет в тексте «что-то про себя в контексте этого вопроса».

Опции всех вопросов склеиваются в одну матрицу U^{(0)} [9times1024]. Это же представление попадает на вход второго EvidenceRoutingLayer

Код

Сборка запросов и вызов слоёв в JointSchemaHead.forward:

option_queries = []
for question_index, (context_vectors, lexical) in enumerate(
    zip(option_contexts, lexical_options)
):
    option_queries.append(
        self.option_context_projection(context_vectors)
        + self.option_lexical_projection(lexical)
        + self.option_question_projection(
            question_vectors[question_index]
        ).unsqueeze(0)
    )
routed_options = torch.cat(option_queries, dim=0).unsqueeze(0)
for layer in self.evidence_layers:
    routed_options = layer(routed_options, memory)
routed_options = routed_options[0]
split_options = list(torch.split(routed_options, option_counts, dim=0))

Выход этапа

R [9times1024] режется обратно на группы вопросов (обогащенные).

Этап C1. Сборка поля для каждого вопроса

Как устроена decision-модель Clef: разбор модели принятия решений - 80

На этом этапе для каждого вопроса собирается один вектор – поле. Поле дальше пойдёт в joint-слои, и в конце с ним будут сравниваться опции.

Вход

Размер

q_k – вектор вопроса

Ktimes5120

R_k – обогащённые опции вопроса

n_ktimes1024

g – последний токен

5120

type_ids – тип вопроса

K

Код
base_fields = self.question_projection(question_vectors)
option_summaries = []
for field, options in zip(base_fields, split_options):
    routing_weights = torch.softmax(
        torch.matmul(options, field) / math.sqrt(options.shape[-1]),
        dim=0,
    )
    option_summaries.append(
        torch.sum(routing_weights.unsqueeze(-1) * options, dim=0)
    )
fields = (
    base_fields
    + self.option_summary_norm(torch.stack(option_summaries))
    + self.global_projection(global_vector).unsqueeze(0)
    + self.type_embedding(type_ids)
)

Шаги по порядку

1. b_k=W_qq_k,qquad W_qinmathbb{R}^{1024times5120},quad b_kinmathbb{R}^{1024}

2. s_{kj}=frac{r_{kj}cdot b_k}{sqrt{1024}}– оценка каждой опции.

3. alpha_{kj}=frac{e^{s_{kj}}}{sum_{j’=1}^{n_k} e^{s_{kj’}}}– Softmax. Распределение опций каждого вопроса.

4. bar{r}_k=sum_j alpha_{kj}r_{kj}=alpha_k^top R_k quad in mathbb{R}^{1024}– получаем вектор, который представляет собой общую картину по всем опциям для каждого вопроса.

5. mathrm{LN}_s(bar r_k) – нормализация.

6. W_gg W_ginmathbb{R}^{1024times5120} – глобальная добавка. Все поля «знают», о чём весь тикет.

7. T_{tau_k} Tinmathbb{R}^{3times1024} – эмбеддинг типа. Таблица из трёх обучаемых строк: noul, choice, score. Поле знает, как будут читаться его опции.

f^{(0)}_k=underbrace{b_k}_{text{вопрос}} + underbrace{mathrm{LN}_s(bar{r}_k)}_{text{его опции}} + underbrace{W_g g}_{text{весь запрос}} + underbrace{T_{tau_k}}_{text{тип}}

Выход этапа

F^{(0)} [3times1024]

Этап C2. Joint-слои ×4

Как устроена decision-модель Clef: разбор модели принятия решений - 99

На этом этапе вопросы впервые узнают друг о друге. Поля обмениваются информацией и ещё раз сверяются с текстом.

Вход

Размер

F^{(0)}

3times1024

mathcal M

Ntimes1024

Код
self.layers = torch.nn.ModuleList(
    [
        torch.nn.TransformerDecoderLayer(
            d_model=width,
            nhead=heads,
            dim_feedforward=feedforward,
            dropout=dropout,
            activation="gelu",
            batch_first=True,
            norm_first=True,
        )
        for _ in range(layers)
    ]
)
self.field_norm = torch.nn.LayerNorm(width)

Вызов в forward:

fields = fields.unsqueeze(0)
for layer in self.layers:
    fields = layer(fields, memory)
fields = self.field_norm(fields[0])

TransformerDecoderLayer– базовый слой декодеров в архитектуре Transformer.

begin{aligned} F &leftarrow F + mathrm{MHA}_{text{self}}big(mathrm{LN}_1 F, mathrm{LN}_1 F, mathrm{LN}_1 Fbig) \ F &leftarrow F + mathrm{MHA}_{text{cross}}big(mathrm{LN}_2 F, mathcal{M}, mathcal{M}big) \ F &leftarrow F + W_2,mathrm{GELU}big(W_1,mathrm{LN}_3 F + b_1big) + b_2 end{aligned}

1. Self-attention: вопросы смотрят друг на друга

Запросы, ключи и значения берутся из одних и тех же полей (16 голов по 64), матрица внимания каждой головы имеет размер:

A_i=mathrm{softmax}Big(frac{Q_iK_i^top}{sqrt{64}}Big)quad[3times3]

Строка – кто смотрит, столбец – на кого. Каждое поле смотрит на все три, включая себя.

2. Cross-attention: поля читают текст

Q=hat FW^Q [3times1024],qquad K=mathcal MW^K, V=mathcal MW^V [Ntimes1024],qquad A_i [3times N]

Смысл: после подблока 1 поле уже знает, что думают другие вопросы, и перечитывает текст с этим знанием. Например, поле severity, получив от urgent сигнал «срочно», может внимательнее посмотреть на «every customer».

3. FFN

1024to4096to1024 с GELU для каждого поля.

Выход этапа

F [3times1024] – финальные поля.

Этап D. Скоринг каждой опции

Как устроена decision-модель Clef: разбор модели принятия решений - 109

Вход

Размер

Пространство

f_k – финальное поле

1024

голова Clef

r_{kj} – вопроса

n_ktimes1024

голова Clef

ell_{kj} – лексические векторы

n_ktimes5120

Qwen

q_k – вектор вопроса

5120

Qwen

g – последний токен

5120

Qwen

Код

Объявление скорера и масштабов в _init_:

self.field_norm = torch.nn.LayerNorm(width)
self.option_norm = torch.nn.LayerNorm(width)
self.residual_scorer = torch.nn.Sequential(
    torch.nn.Linear(width * 4, width),
    torch.nn.GELU(),
    torch.nn.Dropout(dropout),
    torch.nn.Linear(width, 1),
)
self.prior_logit_scale = torch.nn.Parameter(torch.zeros(()))
self.joint_logit_scale = torch.nn.Parameter(torch.zeros(()))
self.residual_gate = torch.nn.Parameter(torch.zeros(()))

в forward:

record_logits: list[torch.Tensor] = []
for field, question, lexical, routed in zip(
    fields,
    record.questions,
    lexical_options,
    split_options,
):
    anchor = functional.normalize(
        question_vectors[len(record_logits)] + global_vector,
        dim=-1,
    )
    lexical_anchor = functional.normalize(lexical, dim=-1)
    prior_scale = self.prior_logit_scale.clamp(max=math.log(100.0)).exp()
    prior = prior_scale * torch.matmul(lexical_anchor, anchor)
    options = self.option_norm(routed)
    repeated_field = field.unsqueeze(0).expand_as(options)
    cosine = functional.cosine_similarity(repeated_field, options, dim=-1)
    features = torch.cat(
        [
            repeated_field,
            options,
            repeated_field * options,
            torch.abs(repeated_field - options),
        ],
        dim=-1,
    )
    residual = self.residual_scorer(features).squeeze(-1)
    joint_scale = self.joint_logit_scale.clamp(max=math.log(100.0)).exp()
    joint = joint_scale * cosine + residual
    record_logits.append(
        prior + torch.sigmoid(self.residual_gate) * joint
    )
results.append(record_logits)
return results

Логит собирается из двух независимых частей:

  • prior работает только с ell, q, g в пространстве Qwen (5120);

  • joint работает только с f и r в пространстве головы (1024).

Часть 1. Лексический prior

a_k=frac{q_k + g}{lvert q_k + g rvert}, qquad lambda_{kj}=frac{ell_{kj}}{lvert ell_{kj} rvert}, qquad pi_{kj}=s_p cdot big(lambda_{kj} cdot a_kbig),qquad s_p=e^{min(theta_p, ln100)}

a_k– смысл вопроса плюс смысл всего запроса.

Лексический вектор lambda_{kj} – словарный смысл слов опции без контекста.

s_p – обучаемый коэффициент масштабирования

Часть 2. Совместная оценка

2a. Нормировка опций: o_{kj}=mathrm{LN}_o(r_{kj})

2b. Схожесть поля и опции:

kappa_{kj}=cos(f_k, o_{kj})=frac{f_k cdot o_{kj}}{lvert f_k rvert , lvert o_{kj} rvert}

2c. MLP (residual_scorer в коде) по признакам пары. Для каждой пары собирается вектор длиной 4096:

phi_{kj}=big[, f_k ; ; ; o_{kj} ; ; ; f_k odot o_{kj} ; ; ; lvert f_k - o_{kj} rvert ,big]

rho_{kj}=v^top mathrm{GELU}big(W_r phi_{kj} + b_rbig) + b, qquad W_r in mathbb{R}^{1024 times 4096}, quad v in mathbb{R}^{1024}

2d. Сборка joint:

eta_{kj}=s_j cdot kappa_{kj} + rho_{kj}, qquad s_j=e^{min(theta_j, ln 100)}

Итоговый логит

z_{kj}=pi_{kj} + sigma(gamma) cdot eta_{kj}

sigma(gamma) – обучаемый вес смешивания

Выход этапа

Список тензоров:

record_logits = [ z_urgent [2],  z_team [3],  z_severity [4] ]

Этап 3. Softmax и ответ

Как устроена decision-модель Clef: разбор модели принятия решений - 133

Применение Softmax в рамках вопроса:

p_{kj}=frac{e^{z_{kj}}}{sum_{j'=1}^{n_k} e^{z_{kj'}}}

И возврат результата пользователю:

def systemone_answer(question: dict[str, Any], probabilities: dict[str, float]) -> dict[str, Any]:
    """Convert per-option probabilities for one question into a SystemOne answer."""
    if question["type"] == "noul":
        return {"type": "noul", "noul": round(probabilities["true"], 4)}
    if question["type"] == "choice":
        options = [str(option) for option in question["criteria"]]
        choice = max(options, key=probabilities.__getitem__)
        return {
            "type": "choice",
            "choice": choice,
            "confidence": round(probabilities[choice], 4),
            "probabilities": {option: round(probabilities[option], 4) for option in options},
        }
    levels = [str(index) for index in range(len(question["criteria"]))]
    return {
        "type": "score",
        "score": round(sum(index * probabilities[level] for index, level in enumerate(levels)), 4),
        "confidence": round(max(probabilities[level] for level in levels), 4),
        "legend": dict(zip(levels, question["criteria"])),
        "probabilities": {level: round(probabilities[level], 4) for level in levels},
    }

Вывод

В этой статье я постарался осветить процесс работы модели принятия решений Clef. Если вы заметили неточность в излагаемом материале – буду рад услышать ваше объяснение и критику для более глубокого понимания работы!

Автор: RadAI

Источник

  • Запись добавлена: 18.10.2026 в 10:56
  • Оставлено в