openthai-systemone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openthai_systemone/__init__.py +21 -0
- openthai_systemone/client.py +109 -0
- openthai_systemone/configuration.py +40 -0
- openthai_systemone/data/__init__.py +0 -0
- openthai_systemone/data/records.py +95 -0
- openthai_systemone/formatting.py +291 -0
- openthai_systemone/modeling.py +210 -0
- openthai_systemone/server.py +44 -0
- openthai_systemone/train_utils.py +127 -0
- openthai_systemone/types.py +135 -0
- openthai_systemone-0.1.0.dist-info/METADATA +104 -0
- openthai_systemone-0.1.0.dist-info/RECORD +15 -0
- openthai_systemone-0.1.0.dist-info/WHEEL +5 -0
- openthai_systemone-0.1.0.dist-info/licenses/LICENSE +202 -0
- openthai_systemone-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .types import Choice, Noul, Score, ChoiceAnswer, NoulAnswer, ScoreAnswer, SystemOneRequest, SystemOneResponse
|
|
2
|
+
from .configuration import OpenThaiSystemOneConfig
|
|
3
|
+
from .modeling import OpenThaiSystemOneForDecision
|
|
4
|
+
from .formatting import Formatter, SPECIAL_TOKENS, N_SLOTS, ABSTAIN_SLOT
|
|
5
|
+
from .client import SystemOneClient
|
|
6
|
+
|
|
7
|
+
from transformers import AutoConfig, AutoModel
|
|
8
|
+
|
|
9
|
+
try: # make AutoConfig/AutoModel aware of the architecture when the package is installed
|
|
10
|
+
AutoConfig.register("openthai_systemone", OpenThaiSystemOneConfig)
|
|
11
|
+
AutoModel.register(OpenThaiSystemOneConfig, OpenThaiSystemOneForDecision)
|
|
12
|
+
except ValueError: # already registered (e.g. remote code + package both imported)
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Choice", "Noul", "Score", "ChoiceAnswer", "NoulAnswer", "ScoreAnswer",
|
|
18
|
+
"SystemOneRequest", "SystemOneResponse",
|
|
19
|
+
"OpenThaiSystemOneConfig", "OpenThaiSystemOneForDecision",
|
|
20
|
+
"Formatter", "SPECIAL_TOKENS", "N_SLOTS", "ABSTAIN_SLOT", "SystemOneClient",
|
|
21
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Local inference: `client.system_one(state, questions)` -> answers, mirroring the TypeSafe SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
from transformers import AutoTokenizer
|
|
8
|
+
|
|
9
|
+
from .formatting import Encoded, Formatter, collate
|
|
10
|
+
from .modeling import OpenThaiSystemOneForDecision, QTYPE_INDEX, confidence_from_probs, use_reference_kernels
|
|
11
|
+
from .types import (
|
|
12
|
+
Answer,
|
|
13
|
+
Choice,
|
|
14
|
+
ChoiceAnswer,
|
|
15
|
+
Noul,
|
|
16
|
+
NoulAnswer,
|
|
17
|
+
Question,
|
|
18
|
+
Score,
|
|
19
|
+
ScoreAnswer,
|
|
20
|
+
SystemOneResponse,
|
|
21
|
+
Usage,
|
|
22
|
+
parse_question,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SystemOneClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
model_path: str,
|
|
30
|
+
*,
|
|
31
|
+
device: Optional[str] = None,
|
|
32
|
+
dtype: Optional[torch.dtype] = None,
|
|
33
|
+
max_total_tokens: int = 65536,
|
|
34
|
+
max_state_tokens: int = 32768,
|
|
35
|
+
model_name: str = "openthai-systemone",
|
|
36
|
+
):
|
|
37
|
+
if device is None:
|
|
38
|
+
device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
|
|
39
|
+
if dtype is None:
|
|
40
|
+
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
|
41
|
+
self.device = device
|
|
42
|
+
if device != "cuda":
|
|
43
|
+
use_reference_kernels()
|
|
44
|
+
self.tok = AutoTokenizer.from_pretrained(model_path)
|
|
45
|
+
self.model = OpenThaiSystemOneForDecision.from_pretrained(model_path, dtype=dtype).to(device).eval()
|
|
46
|
+
self.fmt = Formatter(self.tok, max_total_tokens=max_total_tokens, max_state_tokens=max_state_tokens)
|
|
47
|
+
self.model_name = model_name
|
|
48
|
+
pad = self.tok.pad_token_id
|
|
49
|
+
self.pad_id = pad if pad is not None else (self.tok.eos_token_id or 0)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ public API
|
|
52
|
+
@torch.no_grad()
|
|
53
|
+
def system_one(
|
|
54
|
+
self,
|
|
55
|
+
state: Union[str, Dict[str, Any], List[Any]],
|
|
56
|
+
questions: Dict[str, Union[Question, Dict[str, Any]]],
|
|
57
|
+
) -> SystemOneResponse:
|
|
58
|
+
return self.system_one_batch([(state, questions)])[0]
|
|
59
|
+
|
|
60
|
+
@torch.no_grad()
|
|
61
|
+
def system_one_batch(
|
|
62
|
+
self,
|
|
63
|
+
items: Sequence[tuple],
|
|
64
|
+
) -> List[SystemOneResponse]:
|
|
65
|
+
parsed = [(s, {k: parse_question(v) for k, v in qs.items()}) for s, qs in items]
|
|
66
|
+
encs = [self.fmt.encode(s, qs) for s, qs in parsed]
|
|
67
|
+
batch = collate(encs, self.pad_id)
|
|
68
|
+
qtypes = torch.full_like(batch["option_counts"], -1)
|
|
69
|
+
for b, e in enumerate(encs):
|
|
70
|
+
for qi, spec in enumerate(e.specs):
|
|
71
|
+
qtypes[b, qi] = QTYPE_INDEX[spec.qtype]
|
|
72
|
+
batch = {k: v.to(self.device) for k, v in batch.items()}
|
|
73
|
+
out = self.model(
|
|
74
|
+
input_ids=batch["input_ids"],
|
|
75
|
+
attention_mask=batch["attention_mask"],
|
|
76
|
+
answer_positions=batch["answer_positions"],
|
|
77
|
+
option_counts=batch["option_counts"],
|
|
78
|
+
qtypes=qtypes.to(self.device),
|
|
79
|
+
include_abstain=True,
|
|
80
|
+
)
|
|
81
|
+
probs = out.probs.float().cpu()
|
|
82
|
+
return [self._decode(e, probs[b]) for b, e in enumerate(encs)]
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------ decoding
|
|
85
|
+
def _decode(self, enc: Encoded, probs: torch.Tensor) -> SystemOneResponse:
|
|
86
|
+
answers: Dict[str, Answer] = {}
|
|
87
|
+
for qi, spec in enumerate(enc.specs):
|
|
88
|
+
p = probs[qi]
|
|
89
|
+
k = len(spec.option_names)
|
|
90
|
+
abstain = float(p[self.model.config.abstain_slot])
|
|
91
|
+
pk = p[:k]
|
|
92
|
+
pk = pk / pk.sum().clamp(min=1e-12) # renormalise over the real options
|
|
93
|
+
conf = confidence_from_probs(pk, k)
|
|
94
|
+
if spec.qtype == "noul":
|
|
95
|
+
answers[spec.qid] = NoulAnswer(noul=float(pk[1]))
|
|
96
|
+
elif spec.qtype == "choice":
|
|
97
|
+
prob_map = {spec.option_names[i]: float(pk[i]) for i in range(k)}
|
|
98
|
+
best = max(prob_map, key=prob_map.get)
|
|
99
|
+
answers[spec.qid] = ChoiceAnswer(choice=best, probabilities=prob_map, confidence=conf, abstain=abstain)
|
|
100
|
+
else: # score
|
|
101
|
+
levels = torch.arange(k, dtype=torch.float32)
|
|
102
|
+
score = float((pk * levels).sum())
|
|
103
|
+
answers[spec.qid] = ScoreAnswer(
|
|
104
|
+
score=score,
|
|
105
|
+
legend={i: spec.option_descs[i] or str(i) for i in range(k)},
|
|
106
|
+
probabilities={str(i): float(pk[i]) for i in range(k)},
|
|
107
|
+
confidence=conf,
|
|
108
|
+
)
|
|
109
|
+
return SystemOneResponse(model=self.model_name, answers=answers, usage=Usage(input_tokens=enc.n_tokens))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from transformers import AutoConfig, PretrainedConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenThaiSystemOneConfig(PretrainedConfig):
|
|
9
|
+
"""Config = a text-tower config (Qwen3.5 text by default) + slot-head settings."""
|
|
10
|
+
|
|
11
|
+
model_type = "openthai_systemone"
|
|
12
|
+
sub_configs = {"text_config": AutoConfig}
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
text_config: Optional[Dict[str, Any] | PretrainedConfig] = None,
|
|
17
|
+
n_slots: int = 256,
|
|
18
|
+
abstain_slot: int = 255,
|
|
19
|
+
answer_token_id: Optional[int] = None,
|
|
20
|
+
head_bias: bool = True,
|
|
21
|
+
n_temperatures: int = 3, # per question type: choice / score / noul
|
|
22
|
+
**kwargs,
|
|
23
|
+
):
|
|
24
|
+
if isinstance(text_config, dict):
|
|
25
|
+
text_config = AutoConfig.for_model(**text_config) if "model_type" in text_config else AutoConfig.for_model("qwen3_5_text", **text_config)
|
|
26
|
+
self.text_config = text_config
|
|
27
|
+
self.n_slots = n_slots
|
|
28
|
+
self.abstain_slot = abstain_slot
|
|
29
|
+
self.answer_token_id = answer_token_id
|
|
30
|
+
self.head_bias = head_bias
|
|
31
|
+
self.n_temperatures = n_temperatures
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def hidden_size(self) -> int:
|
|
36
|
+
return self.text_config.hidden_size
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def vocab_size(self) -> int:
|
|
40
|
+
return self.text_config.vocab_size
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Unified decision record format shared by all data builders and the SFT trainer.
|
|
2
|
+
|
|
3
|
+
{
|
|
4
|
+
"id": str, "source": str, "lang": "th"|"en"|"mixed"|..., "split": "train"|"eval",
|
|
5
|
+
"state": str | dict | list,
|
|
6
|
+
"questions": {qid: {"type": "choice"|"score"|"noul", "instructions": str, "criteria": ...}},
|
|
7
|
+
"labels": {qid: option-name | level-index | bool}
|
|
8
|
+
}
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import random
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
# Instruction paraphrases: the model must not overfit to one phrasing per dataset.
|
|
18
|
+
TH_CHOICE_TEMPLATES = [
|
|
19
|
+
"ข้อความนี้จัดอยู่ในหมวด{what}ใด",
|
|
20
|
+
"เลือก{what}ที่ตรงกับข้อความมากที่สุด",
|
|
21
|
+
"จาก state ด้านบน {what}ที่ถูกต้องคือข้อใด",
|
|
22
|
+
"ระบุ{what}ของข้อความนี้",
|
|
23
|
+
]
|
|
24
|
+
EN_CHOICE_TEMPLATES = [
|
|
25
|
+
"Which {what} best matches this text?",
|
|
26
|
+
"Classify the {what} of the state above.",
|
|
27
|
+
"Select the correct {what}.",
|
|
28
|
+
"What is the {what}?",
|
|
29
|
+
]
|
|
30
|
+
TH_NOUL_TEMPLATES = ["ข้อความนี้{what}หรือไม่", "จริงหรือไม่ว่า ข้อความนี้{what}", "state นี้{what}ใช่หรือไม่"]
|
|
31
|
+
EN_NOUL_TEMPLATES = ["Is this text {what}?", "Does the state {what}?", "True or false: the text {what}."]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Record:
|
|
36
|
+
id: str
|
|
37
|
+
source: str
|
|
38
|
+
lang: str
|
|
39
|
+
state: Union[str, Dict[str, Any], List[Any]]
|
|
40
|
+
questions: Dict[str, Dict[str, Any]]
|
|
41
|
+
labels: Dict[str, Union[str, int, bool]]
|
|
42
|
+
split: str = "train"
|
|
43
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def to_json(self) -> str:
|
|
46
|
+
return json.dumps(self.__dict__, ensure_ascii=False)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_json(line: str) -> "Record":
|
|
50
|
+
d = json.loads(line)
|
|
51
|
+
known = {"id", "source", "lang", "state", "questions", "labels", "split", "meta"}
|
|
52
|
+
meta = dict(d.get("meta", {}))
|
|
53
|
+
meta.update({k: v for k, v in d.items() if k not in known})
|
|
54
|
+
return Record(**{k: d[k] for k in ("id", "source", "lang", "state", "questions", "labels")},
|
|
55
|
+
split=d.get("split", "train"), meta=meta)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def choice_q(instructions: str, options: Iterable[str], descs: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, Any]:
|
|
59
|
+
descs = descs or {}
|
|
60
|
+
return {"type": "choice", "instructions": instructions, "criteria": {o: descs.get(o) for o in options}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def score_q(instructions: str, levels: List[str]) -> Dict[str, Any]:
|
|
64
|
+
return {"type": "score", "instructions": instructions, "criteria": list(levels)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def noul_q(instructions: str, true_desc: Optional[str] = None, false_desc: Optional[str] = None) -> Dict[str, Any]:
|
|
68
|
+
q: Dict[str, Any] = {"type": "noul", "instructions": instructions}
|
|
69
|
+
if true_desc or false_desc:
|
|
70
|
+
q["criteria"] = {"true": true_desc, "false": false_desc}
|
|
71
|
+
return q
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pick_template(rng: random.Random, lang: str, kind: str, what: str) -> str:
|
|
75
|
+
if kind == "choice":
|
|
76
|
+
t = TH_CHOICE_TEMPLATES if lang == "th" else EN_CHOICE_TEMPLATES
|
|
77
|
+
else:
|
|
78
|
+
t = TH_NOUL_TEMPLATES if lang == "th" else EN_NOUL_TEMPLATES
|
|
79
|
+
return rng.choice(t).format(what=what)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_jsonl(path, records: Iterable[Record]) -> int:
|
|
83
|
+
n = 0
|
|
84
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
85
|
+
for r in records:
|
|
86
|
+
f.write(r.to_json() + "\n")
|
|
87
|
+
n += 1
|
|
88
|
+
return n
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_jsonl(path) -> Iterator[Record]:
|
|
92
|
+
with open(path, encoding="utf-8") as f:
|
|
93
|
+
for line in f:
|
|
94
|
+
if line.strip():
|
|
95
|
+
yield Record.from_json(line)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Turn (state, questions) into token ids + slot bookkeeping.
|
|
2
|
+
|
|
3
|
+
Layout (one sequence, causal):
|
|
4
|
+
|
|
5
|
+
<|ts_state|> {state text}
|
|
6
|
+
<|ts_q|><|ts_choice|> {instructions}
|
|
7
|
+
<|ts_opt_0|> {option name}: {description}
|
|
8
|
+
<|ts_opt_1|> {option name}
|
|
9
|
+
...
|
|
10
|
+
<|ts_answer|> <- hidden state here -> SlotHead (256 logits)
|
|
11
|
+
<|ts_q|><|ts_noul|> {instructions}
|
|
12
|
+
<|ts_opt_0|> no
|
|
13
|
+
<|ts_opt_1|> yes
|
|
14
|
+
<|ts_answer|>
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Slot i (0..254) means "the option introduced by <|ts_opt_i|>"; slot 255 = abstain.
|
|
18
|
+
All answers for all questions are read out from one forward pass.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import random
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
26
|
+
|
|
27
|
+
from .types import Choice, Noul, Score, Question, MAX_OPTIONS
|
|
28
|
+
|
|
29
|
+
N_SLOTS = 256
|
|
30
|
+
ABSTAIN_SLOT = 255
|
|
31
|
+
|
|
32
|
+
TOK_STATE = "<|ts_state|>"
|
|
33
|
+
TOK_Q = "<|ts_q|>"
|
|
34
|
+
TOK_CHOICE = "<|ts_choice|>"
|
|
35
|
+
TOK_SCORE = "<|ts_score|>"
|
|
36
|
+
TOK_NOUL = "<|ts_noul|>"
|
|
37
|
+
TOK_ANSWER = "<|ts_answer|>"
|
|
38
|
+
TOK_OPT = [f"<|ts_opt_{i}|>" for i in range(N_SLOTS)]
|
|
39
|
+
|
|
40
|
+
SPECIAL_TOKENS: List[str] = [TOK_STATE, TOK_Q, TOK_CHOICE, TOK_SCORE, TOK_NOUL, TOK_ANSWER] + TOK_OPT
|
|
41
|
+
|
|
42
|
+
NOUL_OPTIONS = ("no", "yes") # slot 0 = no, slot 1 = yes -> noul = p(slot 1)
|
|
43
|
+
|
|
44
|
+
DEFAULT_MAX_TOTAL_TOKENS = 65536
|
|
45
|
+
DEFAULT_MAX_STATE_TOKENS = 32768
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def add_special_tokens(tokenizer) -> int:
|
|
49
|
+
"""Register the TypeSafe control tokens. Returns number of tokens added."""
|
|
50
|
+
existing = set(tokenizer.get_vocab())
|
|
51
|
+
new = [t for t in SPECIAL_TOKENS if t not in existing]
|
|
52
|
+
if not new:
|
|
53
|
+
return 0
|
|
54
|
+
return tokenizer.add_tokens(new, special_tokens=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sanitize(text: str) -> str:
|
|
58
|
+
"""Stop user content from smuggling control tokens into the sequence."""
|
|
59
|
+
return text.replace("<|ts_", "<|ts_") if "<|ts_" in text else text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def state_to_text(state: Union[str, Dict[str, Any], List[Any]], *, indent: Optional[int] = None) -> str:
|
|
63
|
+
if isinstance(state, str):
|
|
64
|
+
return state
|
|
65
|
+
return json.dumps(state, ensure_ascii=False, indent=indent)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class QuestionSpec:
|
|
70
|
+
"""A question flattened to option strings + slot bookkeeping."""
|
|
71
|
+
|
|
72
|
+
qid: str
|
|
73
|
+
qtype: str # choice | score | noul
|
|
74
|
+
instructions: str
|
|
75
|
+
option_names: List[str] # in slot order (after any permutation)
|
|
76
|
+
option_descs: List[Optional[str]]
|
|
77
|
+
perm: List[int] # perm[slot] = original index of the option at that slot
|
|
78
|
+
label_slot: Optional[int] = None # training only
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def question_to_spec(
|
|
82
|
+
qid: str,
|
|
83
|
+
q: Question,
|
|
84
|
+
*,
|
|
85
|
+
label: Optional[Union[str, int, bool]] = None,
|
|
86
|
+
shuffle: bool = False,
|
|
87
|
+
rng: Optional[random.Random] = None,
|
|
88
|
+
drop_label: bool = False,
|
|
89
|
+
) -> QuestionSpec:
|
|
90
|
+
"""Flatten a typed question.
|
|
91
|
+
|
|
92
|
+
label: for training. Choice -> option name; Score -> level index (int); Noul -> bool.
|
|
93
|
+
shuffle: permute option order (Choice only; Score/Noul order is semantic).
|
|
94
|
+
drop_label: remove the correct option from a Choice so the target becomes ABSTAIN_SLOT.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(q, Choice):
|
|
97
|
+
names = list(q.criteria.keys())
|
|
98
|
+
descs = [q.criteria[n] for n in names]
|
|
99
|
+
idx = list(range(len(names)))
|
|
100
|
+
label_idx = None
|
|
101
|
+
if label is not None:
|
|
102
|
+
if label not in q.criteria:
|
|
103
|
+
raise ValueError(f"label {label!r} is not one of the options")
|
|
104
|
+
label_idx = names.index(str(label))
|
|
105
|
+
if drop_label and label_idx is not None:
|
|
106
|
+
if len(idx) < 2:
|
|
107
|
+
raise ValueError("cannot drop the only option")
|
|
108
|
+
idx.remove(label_idx)
|
|
109
|
+
label_idx = None
|
|
110
|
+
if shuffle:
|
|
111
|
+
(rng or random).shuffle(idx)
|
|
112
|
+
names_p = [names[i] for i in idx]
|
|
113
|
+
descs_p = [descs[i] for i in idx]
|
|
114
|
+
if label is None:
|
|
115
|
+
slot = None
|
|
116
|
+
elif label_idx is None:
|
|
117
|
+
slot = ABSTAIN_SLOT
|
|
118
|
+
else:
|
|
119
|
+
slot = idx.index(label_idx)
|
|
120
|
+
return QuestionSpec(qid, "choice", q.instructions, names_p, descs_p, idx, slot)
|
|
121
|
+
|
|
122
|
+
if isinstance(q, Score):
|
|
123
|
+
names = [str(i) for i in range(len(q.criteria))]
|
|
124
|
+
descs = list(q.criteria)
|
|
125
|
+
slot = int(label) if label is not None else None
|
|
126
|
+
if slot is not None and not (0 <= slot < len(descs)):
|
|
127
|
+
raise ValueError("score label out of range")
|
|
128
|
+
return QuestionSpec(qid, "score", q.instructions, names, descs, list(range(len(names))), slot)
|
|
129
|
+
|
|
130
|
+
if isinstance(q, Noul):
|
|
131
|
+
c = q.criteria or {}
|
|
132
|
+
descs = [c.get("false"), c.get("true")]
|
|
133
|
+
slot = None if label is None else int(bool(label))
|
|
134
|
+
return QuestionSpec(qid, "noul", q.instructions, list(NOUL_OPTIONS), descs, [0, 1], slot)
|
|
135
|
+
|
|
136
|
+
raise TypeError(type(q))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def spec_to_text(spec: QuestionSpec) -> str:
|
|
140
|
+
head = {"choice": TOK_CHOICE, "score": TOK_SCORE, "noul": TOK_NOUL}[spec.qtype]
|
|
141
|
+
lines = [f"{TOK_Q}{head} {sanitize(spec.instructions).strip()}"]
|
|
142
|
+
for i, (name, desc) in enumerate(zip(spec.option_names, spec.option_descs)):
|
|
143
|
+
name = sanitize(str(name)).strip()
|
|
144
|
+
if desc:
|
|
145
|
+
lines.append(f"{TOK_OPT[i]} {name}: {sanitize(str(desc)).strip()}")
|
|
146
|
+
else:
|
|
147
|
+
lines.append(f"{TOK_OPT[i]} {name}")
|
|
148
|
+
lines.append(TOK_ANSWER)
|
|
149
|
+
return "\n".join(lines) + "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Encoded:
|
|
154
|
+
input_ids: List[int]
|
|
155
|
+
answer_positions: List[int] # index of each <|ts_answer|> token, question order
|
|
156
|
+
option_counts: List[int] # k per question (valid slots 0..k-1)
|
|
157
|
+
specs: List[QuestionSpec]
|
|
158
|
+
labels: List[int] = field(default_factory=list) # -100 if unknown
|
|
159
|
+
truncated_state: bool = False
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def n_tokens(self) -> int:
|
|
163
|
+
return len(self.input_ids)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Formatter:
|
|
167
|
+
"""Tokenizer-aware encoder shared by training and inference."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
tokenizer,
|
|
172
|
+
*,
|
|
173
|
+
max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS,
|
|
174
|
+
max_state_tokens: int = DEFAULT_MAX_STATE_TOKENS,
|
|
175
|
+
):
|
|
176
|
+
self.tok = tokenizer
|
|
177
|
+
add_special_tokens(self.tok)
|
|
178
|
+
self.max_total_tokens = max_total_tokens
|
|
179
|
+
self.max_state_tokens = max_state_tokens
|
|
180
|
+
self.answer_id = self.tok.convert_tokens_to_ids(TOK_ANSWER)
|
|
181
|
+
self.state_id = self.tok.convert_tokens_to_ids(TOK_STATE)
|
|
182
|
+
self.opt_ids = self.tok.convert_tokens_to_ids(TOK_OPT)
|
|
183
|
+
assert self.answer_id is not None and self.answer_id != self.tok.unk_token_id
|
|
184
|
+
|
|
185
|
+
def _ids(self, text: str) -> List[int]:
|
|
186
|
+
return self.tok(text, add_special_tokens=False)["input_ids"]
|
|
187
|
+
|
|
188
|
+
def encode(
|
|
189
|
+
self,
|
|
190
|
+
state: Union[str, Dict[str, Any], List[Any]],
|
|
191
|
+
questions: Dict[str, Question],
|
|
192
|
+
*,
|
|
193
|
+
labels: Optional[Dict[str, Union[str, int, bool]]] = None,
|
|
194
|
+
shuffle_options: bool = False,
|
|
195
|
+
shuffle_questions: bool = False,
|
|
196
|
+
drop_label_for: Optional[Sequence[str]] = None,
|
|
197
|
+
rng: Optional[random.Random] = None,
|
|
198
|
+
state_indent: Optional[int] = None,
|
|
199
|
+
) -> Encoded:
|
|
200
|
+
rng = rng or random.Random()
|
|
201
|
+
labels = labels or {}
|
|
202
|
+
drop = set(drop_label_for or [])
|
|
203
|
+
qids = list(questions.keys())
|
|
204
|
+
if shuffle_questions:
|
|
205
|
+
rng.shuffle(qids)
|
|
206
|
+
|
|
207
|
+
specs = [
|
|
208
|
+
question_to_spec(
|
|
209
|
+
qid,
|
|
210
|
+
questions[qid],
|
|
211
|
+
label=labels.get(qid),
|
|
212
|
+
shuffle=shuffle_options,
|
|
213
|
+
rng=rng,
|
|
214
|
+
drop_label=qid in drop,
|
|
215
|
+
)
|
|
216
|
+
for qid in qids
|
|
217
|
+
]
|
|
218
|
+
q_texts = [spec_to_text(s) for s in specs]
|
|
219
|
+
q_ids = [self._ids(t) for t in q_texts]
|
|
220
|
+
q_total = sum(len(x) for x in q_ids)
|
|
221
|
+
|
|
222
|
+
state_text = sanitize(state_to_text(state, indent=state_indent))
|
|
223
|
+
state_ids = self._ids(TOK_STATE + " " + state_text.strip() + "\n")
|
|
224
|
+
budget = min(self.max_state_tokens, self.max_total_tokens - q_total)
|
|
225
|
+
truncated = False
|
|
226
|
+
if len(state_ids) > budget:
|
|
227
|
+
# keep the head (state token) and the tail of the state; the end is usually the most recent info
|
|
228
|
+
keep_tail = max(budget - 1, 0)
|
|
229
|
+
state_ids = state_ids[:1] + state_ids[len(state_ids) - keep_tail :]
|
|
230
|
+
truncated = True
|
|
231
|
+
|
|
232
|
+
ids: List[int] = list(state_ids)
|
|
233
|
+
answer_positions: List[int] = []
|
|
234
|
+
for qi in q_ids:
|
|
235
|
+
ids.extend(qi)
|
|
236
|
+
# the answer token is the last non-newline token of each question block
|
|
237
|
+
pos = len(ids) - 1
|
|
238
|
+
while ids[pos] != self.answer_id:
|
|
239
|
+
pos -= 1
|
|
240
|
+
answer_positions.append(pos)
|
|
241
|
+
|
|
242
|
+
return Encoded(
|
|
243
|
+
input_ids=ids,
|
|
244
|
+
answer_positions=answer_positions,
|
|
245
|
+
option_counts=[len(s.option_names) for s in specs],
|
|
246
|
+
specs=specs,
|
|
247
|
+
labels=[(-100 if s.label_slot is None else s.label_slot) for s in specs],
|
|
248
|
+
truncated_state=truncated,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def slot_mask(option_counts: Sequence[int], *, include_abstain: bool = True, n_slots: int = N_SLOTS):
|
|
253
|
+
"""Boolean mask (Q, n_slots): True where a slot is valid for that question."""
|
|
254
|
+
import torch
|
|
255
|
+
|
|
256
|
+
k = torch.as_tensor(list(option_counts), dtype=torch.long)
|
|
257
|
+
ar = torch.arange(n_slots)
|
|
258
|
+
mask = ar[None, :] < k[:, None]
|
|
259
|
+
if include_abstain:
|
|
260
|
+
mask[:, ABSTAIN_SLOT] = True
|
|
261
|
+
return mask
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def collate(encoded: Sequence[Encoded], pad_id: int, *, max_questions: Optional[int] = None):
|
|
265
|
+
"""Right-pad a batch. Returns dict of tensors for OpenThaiSystemOneForDecision.forward."""
|
|
266
|
+
import torch
|
|
267
|
+
|
|
268
|
+
B = len(encoded)
|
|
269
|
+
T = max(e.n_tokens for e in encoded)
|
|
270
|
+
Q = max_questions or max(len(e.answer_positions) for e in encoded)
|
|
271
|
+
input_ids = torch.full((B, T), pad_id, dtype=torch.long)
|
|
272
|
+
attention_mask = torch.zeros((B, T), dtype=torch.long)
|
|
273
|
+
answer_positions = torch.zeros((B, Q), dtype=torch.long)
|
|
274
|
+
option_counts = torch.zeros((B, Q), dtype=torch.long)
|
|
275
|
+
labels = torch.full((B, Q), -100, dtype=torch.long)
|
|
276
|
+
for b, e in enumerate(encoded):
|
|
277
|
+
n = e.n_tokens
|
|
278
|
+
input_ids[b, :n] = torch.tensor(e.input_ids)
|
|
279
|
+
attention_mask[b, :n] = 1
|
|
280
|
+
q = len(e.answer_positions)
|
|
281
|
+
answer_positions[b, :q] = torch.tensor(e.answer_positions)
|
|
282
|
+
option_counts[b, :q] = torch.tensor(e.option_counts)
|
|
283
|
+
if e.labels:
|
|
284
|
+
labels[b, :q] = torch.tensor(e.labels)
|
|
285
|
+
return {
|
|
286
|
+
"input_ids": input_ids,
|
|
287
|
+
"attention_mask": attention_mask,
|
|
288
|
+
"answer_positions": answer_positions,
|
|
289
|
+
"option_counts": option_counts,
|
|
290
|
+
"labels": labels,
|
|
291
|
+
}
|