decision-2b-preview / runtime /scripts /minicpm_pointer.py
denti's picture
Publish frozen native-pointer evaluation preview
df57b75 verified
Raw History Blame Contribute Delete
9.87 kB
"""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