File size: 9,869 Bytes
df57b75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
"""MiniCPM adapter for the pinned Apache-2.0 Kev pointer implementation.

No Qwen token IDs are reused. Structural markers are five existing MiniCPM
unused tokens. All user angle brackets are escaped before tokenization.
Gold labels enter only the returned training targets, never the input payload.
"""
from copy import deepcopy

from decision_data import api_payload, gold_key, question_keys
from kev.api import SystemOneRequest, to_record
from kev.model import SPECIAL, DecisionModel, encode

MARKERS = ['<unused_token_' + str(i) + '>' for i in range(5)]
# Five existing, distinct, single-token specials per vocabulary family, tried in this order. MiniCPM (2B and
# 1B share one tokenizer) has unused tokens; Qwen vocabularies carry Kev's own five specials, which the
# pinned Kev recipe was written for.
MARKER_SETS = {'minicpm': MARKERS, 'kev': list(SPECIAL)}


def choose_markers(tokenizer):
    """(family, markers, ids): the first family whose five markers exist as distinct single tokens."""
    for family, markers in MARKER_SETS.items():
        ids = [tokenizer.convert_tokens_to_ids(t) for t in markers]
        if len(set(ids)) != 5 or any(i is None or i == tokenizer.unk_token_id for i in ids):
            continue
        if all(tokenizer(t, add_special_tokens=False).input_ids == [i] for t, i in zip(markers, ids)):
            return family, list(markers), ids
    raise ValueError('No vocabulary family with five distinct existing single-token structural markers')


class StructuralTokenizer:
    def __init__(self, tokenizer):
        self.tokenizer = tokenizer
        self.family, self.markers, self.marker_ids = choose_markers(tokenizer)
        self.mapping = dict(zip(SPECIAL, self.markers))
        self.pad_token_id = tokenizer.pad_token_id

    def convert_tokens_to_ids(self, text):
        return self.tokenizer.convert_tokens_to_ids(self.mapping.get(text, text))

    def __call__(self, text, **kwargs):
        # Block the structural markers of every family and other existing controls in inputs.
        return self.tokenizer(text.replace('<', '<').replace('>', '>'), **kwargs)


def encode_record(tokenizer, record, max_tokens=2048, option_isolation=False):
    wire = api_payload(record)
    rec, _ = to_record(SystemOneRequest.model_validate(wire))
    result = encode(tokenizer, rec, strict=True, max_state=max_tokens,
                    max_branch=max_tokens, option_isolation=option_isolation)
    if len(result['ids']) > max_tokens:
        raise ValueError('Packed input exceeds the declared context cap')
    result['labels'] = [question_keys(q).index(gold_key(q)) for q in record['questions'].values()]
    result['question_ids'] = list(record['questions'])
    result['soft'] = [soft_target(q) for q in record['questions'].values()]
    result['ordinal'] = [q['type'] == 'score' for q in record['questions'].values()]
    # Option keys travel with the encoding so an anchor distribution can be matched by key, not by position.
    result['option_keys'] = [question_keys(q) for q in record['questions'].values()]
    return result


def ranked_probability_score(probabilities, gold):
    """Mean squared error between the predicted and the observed cumulative distribution.

    This is the objective the pinned Apache-2.0 Kev recipe records as `ranked_probability_score` for Score
    questions. Unlike cross entropy it charges for how far the predicted mass sits from the true level, which
    is what our error map needs: on the five-level replay questions 115 of our 142 errors are off by exactly
    one level, so the ordering is nearly right and only the distance is unpriced.
    """
    if len(probabilities) < 2 or not 0 <= gold < len(probabilities):
        raise ValueError('Invalid ordered scale')
    total, running = 0., 0.
    for i in range(len(probabilities) - 1):
        running += probabilities[i]
        total += (running - (1. if i >= gold else 0.)) ** 2
    return total / (len(probabilities) - 1)


def spherical_score(probabilities, target):
    """Spherical scoring rule: the target-weighted probability divided by the vector's own length.

    Taken from the open Laya implementation of this contract, whose reward is
    `log score + 0.75 * spherical - 1.0 * ranked probability score`. It is strictly proper like log loss but
    bounded, so it keeps rewarding a confident correct answer where log loss has already saturated, and it
    punishes spreading mass thinly across options. Returned as a score to maximise; the trainer subtracts it.
    """
    if len(probabilities) != len(target) or not probabilities:
        raise ValueError('Distribution and target must share a length')
    norm = sum(p * p for p in probabilities) ** .5
    if norm <= 0:
        raise ValueError('Degenerate distribution')
    return sum(t * p for t, p in zip(target, probabilities)) / norm


def ordinal_target(levels, gold, smoothing):
    """Neighbour-smoothed target for an ordered scale.

    A plain one-hot target treats every wrong level as equally wrong, which is false for Score:
    predicting 4 when the answer is 5 is not the same mistake as predicting 1. The smoothing mass is
    split between the immediate neighbours, so the ordering is expressed in the target itself and the
    loss stays an ordinary log loss.
    """
    if not 0 <= smoothing < 1:
        raise ValueError('Ordinal smoothing must be in [0, 1)')
    if levels < 2 or not 0 <= gold < levels:
        raise ValueError('Invalid ordered scale')
    if smoothing == 0:
        return None
    target = [0.] * levels
    target[gold] = 1 - smoothing
    neighbours = [i for i in (gold - 1, gold + 1) if 0 <= i < levels]
    for i in neighbours:
        target[i] = smoothing / len(neighbours)
    return target


def soft_target(question):
    """Exact target distribution for a question that carries one, aligned with the option order.

    Returns None when the question has no distribution, or when augmentation changed the option set,
    so such a question keeps the ordinary hard cross entropy.
    """
    given = question.get('probabilities')
    if not given:
        return None
    keys = question_keys(question)
    if set(keys) != set(given):
        return None
    vector = [float(given[k]) for k in keys]
    total = sum(vector)
    if abs(total - 1) > 1e-6 or any(v < 0 for v in vector):
        raise ValueError('Question probabilities are not a distribution')
    return vector


def permute_choices(record, rng):
    result = deepcopy(record)
    for question in result['questions'].values():
        # Score levels and boolean order have fixed semantics.
        if question['type'] == 'choice':
            items = list(question['criteria'].items())
            rng.shuffle(items)
            question['criteria'] = dict(items)
    return result


def permuted_pair(record, rng):
    """A copy with every choice question's options reordered, plus the mapping back to the original order.

    perms[q][j] is the original index of the option that now sits at position j, or None when the question
    was left alone. Labels are option keys, so they stay correct without translation; only the logits need
    realigning before the two predictions can be compared.
    """
    result = deepcopy(record)
    perms = []
    for question in result['questions'].values():
        if question['type'] == 'choice' and len(question['criteria']) >= 3:
            items = list(question['criteria'].items())
            order = list(range(len(items)))
            rng.shuffle(order)
            question['criteria'] = dict(items[i] for i in order)
            perms.append(order)
        else:
            perms.append(None)
    return result, perms


def load_pointer(path, tokenizer, precision='bf16', option_isolation=False, lora_targets='all', lora_rank=16,
                 revision=None, train_markers=True):
    """The pointer model. `train_markers=False` keeps the five structural-token embedding rows frozen (Kev's
    default); every checkpoint's recipe records the choice as `train_structural_embeddings`, and the loaders
    pass it back here so the adapter keys match what was saved."""
    import torch
    if precision not in ['bf16', 'fp32']:
        raise ValueError('Unsupported precision')
    model = DecisionModel(path, tokenizer, 'cuda', lora=lora_rank, head_dim=256, revision=revision,
                          dtype=torch.bfloat16 if precision == 'bf16' else torch.float32,
                          attn='sdpa', special_embeddings=train_markers, option_isolation=option_isolation,
                          lora_targets=lora_targets)
    if not train_markers:
        model.lm.config.use_cache = False
        model.lm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
        return model
    # Unused pretrained rows may be near zero. Repeated RMSNorm through 42
    # layers then gives enormous embedding gradients. Start the five trainable
    # rows from meaningful existing word embeddings; PEFT saves these rows.
    words = ['state', 'question', 'option', 'end', 'decision']
    with torch.no_grad():
        embedding = model.lm.get_input_embeddings()
        rows = []
        for word in words:
            ids = tokenizer.tokenizer(word, add_special_tokens=False).input_ids
            rows.append(embedding(torch.tensor(ids, device='cuda')).mean(0))
        found = [(n, p) for n, p in model.named_parameters() if n.endswith('trainable_tokens_delta.default')]
        if len(found) != 1 or found[0][1].shape != (5, model.lm.config.hidden_size):
            raise ValueError('Unexpected PEFT structural-embedding state')
        found[0][1].copy_(torch.stack(rows).to(found[0][1].dtype))
    model.lm.config.use_cache = False
    model.lm.gradient_checkpointing_enable(gradient_checkpointing_kwargs={'use_reentrant': False})
    return model