Text Classification
Transformers
Safetensors
Thai
English
openthai_systemone
feature-extraction
system-one
decision-model
thai
qwen3.5
quantized
bnb
custom_code
8-bit precision
bitsandbytes
Instructions to use iapp/OpenThai-SystemOne-bnb-8bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use iapp/OpenThai-SystemOne-bnb-8bit with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="iapp/OpenThai-SystemOne-bnb-8bit", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("iapp/OpenThai-SystemOne-bnb-8bit", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
OpenThai-SystemOne v0.3 (f3709948) — bnb-8bit
Browse files- .gitattributes +1 -0
- README.md +82 -0
- chat_template.jinja +154 -0
- config.json +131 -0
- configuration.py +64 -0
- formatting.py +543 -0
- images.py +234 -0
- model.safetensors +3 -0
- modeling.py +509 -0
- tokenizer.json +3 -0
- tokenizer_config.json +32 -0
- types.py +205 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
language: [th, en]
|
| 4 |
+
base_model: iapp/OpenThai-SystemOne
|
| 5 |
+
base_model_relation: quantized
|
| 6 |
+
library_name: transformers
|
| 7 |
+
pipeline_tag: text-classification
|
| 8 |
+
tags: [system-one, decision-model, thai, qwen3.5, quantized, bnb]
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# OpenThai-SystemOne — bnb-8bit
|
| 12 |
+
|
| 13 |
+
[OpenThai-SystemOne](https://huggingface.co/iapp/OpenThai-SystemOne) is an open Thai + English **System One decision model**: one forward
|
| 14 |
+
pass answers typed questions (`choice` over up to 255 options, ordinal `score`, yes/no `noul`) about a text / JSON state with
|
| 15 |
+
calibrated probabilities, no text generation. It is a Qwen3.5-0.8B text tower (Thai continued pre-training) plus a 256-slot
|
| 16 |
+
decision head. This repo is a quantization of **v0.3** (commit `f3709948`).
|
| 17 |
+
|
| 18 |
+
**What is quantized:** the Linear layers of the tower. The token embeddings, the 256-slot decision head and the per-type temperatures stay in bf16. Quantization therefore only perturbs the hidden state the head reads.
|
| 19 |
+
|
| 20 |
+
**Format:** bitsandbytes LLM.int8 8-bit. bitsandbytes checkpoint, stays quantized in GPU memory (CUDA).
|
| 21 |
+
|
| 22 |
+
Size: 1009 MB (bf16 original: 1,509 MB).
|
| 23 |
+
|
| 24 |
+
## Usage
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
pip install torch transformers safetensors pydantic && pip install bitsandbytes
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
```python
|
| 31 |
+
from transformers import AutoModel, AutoTokenizer # trust_remote_code files are in this repo
|
| 32 |
+
model = AutoModel.from_pretrained("iapp/OpenThai-SystemOne-bnb-8bit", trust_remote_code=True)
|
| 33 |
+
# or with the pip package (git+https://github.com/iapp-technology/openthai-systemone):
|
| 34 |
+
from openthai_systemone import SystemOneClient
|
| 35 |
+
c = SystemOneClient("iapp/OpenThai-SystemOne-bnb-8bit")
|
| 36 |
+
r = c.system_one("ร้านนี้อาหารอร่อยมาก แต่รอนานเกือบชั่วโมง", {"sentiment": {"type": "choice", "instructions": "ความรู้สึก",
|
| 37 |
+
"criteria": {"บวก": None, "ลบ": None, "กลาง": None}}})
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Accuracy vs the bf16 original (same records, single option order, first 800 per set)
|
| 41 |
+
|
| 42 |
+
Macro: public 73.9 (original 74.3), Thai 80.0 (original 80.1).
|
| 43 |
+
|
| 44 |
+
| subset | bf16 original | this | Δ |
|
| 45 |
+
|---|---|---|---|
|
| 46 |
+
| **public 13-subset bench** | | | |
|
| 47 |
+
| aegis2 (noul) | 83.2 | 83.2 | +0.0 |
|
| 48 |
+
| boolq (noul) | 79.7 | 79.3 | -0.3 |
|
| 49 |
+
| civil_comments (noul) | 79.0 | 78.0 | -1.0 |
|
| 50 |
+
| helpsteer2 (score) | 41.6 | 41.6 | +0.0 |
|
| 51 |
+
| massive-de-DE (choice) | 88.3 | 87.4 | -0.9 |
|
| 52 |
+
| massive-en-US (choice) | 88.3 | 88.0 | -0.3 |
|
| 53 |
+
| multinli (choice) | 89.0 | 89.6 | +0.7 |
|
| 54 |
+
| paws (noul) | 94.0 | 93.6 | -0.4 |
|
| 55 |
+
| pubmedqa (choice) | 64.0 | 64.0 | +0.0 |
|
| 56 |
+
| squad2 (noul) | 89.3 | 88.0 | -1.3 |
|
| 57 |
+
| summeval-consistency (score) | 75.0 | 75.7 | +0.7 |
|
| 58 |
+
| summeval-relevance (score) | 21.7 | 20.8 | -0.8 |
|
| 59 |
+
| vitaminc-dev (choice) | 72.5 | 71.3 | -1.2 |
|
| 60 |
+
| *macro, public 13-subset bench* | *74.3* | *73.9* | *-0.4* |
|
| 61 |
+
| **Thai held-out / eval sets** | | | |
|
| 62 |
+
| banking77 (choice) | 59.1 | 59.5 | +0.4 |
|
| 63 |
+
| contrastive_th (choice) | 80.7 | 81.4 | +0.7 |
|
| 64 |
+
| contrastive_th (noul) | 83.5 | 83.9 | +0.4 |
|
| 65 |
+
| contrastive_th (score) | 78.6 | 75.0 | -3.6 |
|
| 66 |
+
| massive_th (choice) | 90.6 | 90.5 | -0.1 |
|
| 67 |
+
| prachathai (choice) | 98.3 | 98.5 | +0.2 |
|
| 68 |
+
| prachathai (noul) | 93.4 | 93.2 | -0.2 |
|
| 69 |
+
| sib200_th (choice) | 77.9 | 78.4 | +0.5 |
|
| 70 |
+
| wisesight (choice) | 48.9 | 49.4 | +0.5 |
|
| 71 |
+
| wongnai (score) | 64.5 | 64.5 | +0.0 |
|
| 72 |
+
| xlam_tools (choice) | 99.4 | 99.4 | +0.0 |
|
| 73 |
+
| xnli_th (choice) | 79.8 | 79.1 | -0.6 |
|
| 74 |
+
| xnli_th (noul) | 86.8 | 86.5 | -0.2 |
|
| 75 |
+
| *macro, Thai held-out / eval sets* | *80.1* | *80.0* | *-0.2* |
|
| 76 |
+
|
| 77 |
+
## Notes
|
| 78 |
+
|
| 79 |
+
- Scores are single-option-order accuracy on the first 800 records of each set (`scripts/06_eval.py --limit 800`), the same
|
| 80 |
+
records for the original and the quantization. `score` subsets report exact level accuracy.
|
| 81 |
+
- Base model, data, training and the full benchmark tables: [iapp/OpenThai-SystemOne](https://huggingface.co/iapp/OpenThai-SystemOne).
|
| 82 |
+
- License Apache-2.0 (same as the base). Built by iApp Technology / OpenThaiGPT.
|
chat_template.jinja
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{%- set image_count = namespace(value=0) %}
|
| 2 |
+
{%- set video_count = namespace(value=0) %}
|
| 3 |
+
{%- macro render_content(content, do_vision_count, is_system_content=false) %}
|
| 4 |
+
{%- if content is string %}
|
| 5 |
+
{{- content }}
|
| 6 |
+
{%- elif content is iterable and content is not mapping %}
|
| 7 |
+
{%- for item in content %}
|
| 8 |
+
{%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
|
| 9 |
+
{%- if is_system_content %}
|
| 10 |
+
{{- raise_exception('System message cannot contain images.') }}
|
| 11 |
+
{%- endif %}
|
| 12 |
+
{%- if do_vision_count %}
|
| 13 |
+
{%- set image_count.value = image_count.value + 1 %}
|
| 14 |
+
{%- endif %}
|
| 15 |
+
{%- if add_vision_id %}
|
| 16 |
+
{{- 'Picture ' ~ image_count.value ~ ': ' }}
|
| 17 |
+
{%- endif %}
|
| 18 |
+
{{- '<|vision_start|><|image_pad|><|vision_end|>' }}
|
| 19 |
+
{%- elif 'video' in item or item.type == 'video' %}
|
| 20 |
+
{%- if is_system_content %}
|
| 21 |
+
{{- raise_exception('System message cannot contain videos.') }}
|
| 22 |
+
{%- endif %}
|
| 23 |
+
{%- if do_vision_count %}
|
| 24 |
+
{%- set video_count.value = video_count.value + 1 %}
|
| 25 |
+
{%- endif %}
|
| 26 |
+
{%- if add_vision_id %}
|
| 27 |
+
{{- 'Video ' ~ video_count.value ~ ': ' }}
|
| 28 |
+
{%- endif %}
|
| 29 |
+
{{- '<|vision_start|><|video_pad|><|vision_end|>' }}
|
| 30 |
+
{%- elif 'text' in item %}
|
| 31 |
+
{{- item.text }}
|
| 32 |
+
{%- else %}
|
| 33 |
+
{{- raise_exception('Unexpected item type in content.') }}
|
| 34 |
+
{%- endif %}
|
| 35 |
+
{%- endfor %}
|
| 36 |
+
{%- elif content is none or content is undefined %}
|
| 37 |
+
{{- '' }}
|
| 38 |
+
{%- else %}
|
| 39 |
+
{{- raise_exception('Unexpected content type.') }}
|
| 40 |
+
{%- endif %}
|
| 41 |
+
{%- endmacro %}
|
| 42 |
+
{%- if not messages %}
|
| 43 |
+
{{- raise_exception('No messages provided.') }}
|
| 44 |
+
{%- endif %}
|
| 45 |
+
{%- if tools and tools is iterable and tools is not mapping %}
|
| 46 |
+
{{- '<|im_start|>system\n' }}
|
| 47 |
+
{{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
|
| 48 |
+
{%- for tool in tools %}
|
| 49 |
+
{{- "\n" }}
|
| 50 |
+
{{- tool | tojson }}
|
| 51 |
+
{%- endfor %}
|
| 52 |
+
{{- "\n</tools>" }}
|
| 53 |
+
{{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
|
| 54 |
+
{%- if messages[0].role == 'system' %}
|
| 55 |
+
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
| 56 |
+
{%- if content %}
|
| 57 |
+
{{- '\n\n' + content }}
|
| 58 |
+
{%- endif %}
|
| 59 |
+
{%- endif %}
|
| 60 |
+
{{- '<|im_end|>\n' }}
|
| 61 |
+
{%- else %}
|
| 62 |
+
{%- if messages[0].role == 'system' %}
|
| 63 |
+
{%- set content = render_content(messages[0].content, false, true)|trim %}
|
| 64 |
+
{{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
|
| 65 |
+
{%- endif %}
|
| 66 |
+
{%- endif %}
|
| 67 |
+
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
| 68 |
+
{%- for message in messages[::-1] %}
|
| 69 |
+
{%- set index = (messages|length - 1) - loop.index0 %}
|
| 70 |
+
{%- if ns.multi_step_tool and message.role == "user" %}
|
| 71 |
+
{%- set content = render_content(message.content, false)|trim %}
|
| 72 |
+
{%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
|
| 73 |
+
{%- set ns.multi_step_tool = false %}
|
| 74 |
+
{%- set ns.last_query_index = index %}
|
| 75 |
+
{%- endif %}
|
| 76 |
+
{%- endif %}
|
| 77 |
+
{%- endfor %}
|
| 78 |
+
{%- if ns.multi_step_tool %}
|
| 79 |
+
{{- raise_exception('No user query found in messages.') }}
|
| 80 |
+
{%- endif %}
|
| 81 |
+
{%- for message in messages %}
|
| 82 |
+
{%- set content = render_content(message.content, true)|trim %}
|
| 83 |
+
{%- if message.role == "system" %}
|
| 84 |
+
{%- if not loop.first %}
|
| 85 |
+
{{- raise_exception('System message must be at the beginning.') }}
|
| 86 |
+
{%- endif %}
|
| 87 |
+
{%- elif message.role == "user" %}
|
| 88 |
+
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
|
| 89 |
+
{%- elif message.role == "assistant" %}
|
| 90 |
+
{%- set reasoning_content = '' %}
|
| 91 |
+
{%- if message.reasoning_content is string %}
|
| 92 |
+
{%- set reasoning_content = message.reasoning_content %}
|
| 93 |
+
{%- else %}
|
| 94 |
+
{%- if '</think>' in content %}
|
| 95 |
+
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
|
| 96 |
+
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
|
| 97 |
+
{%- endif %}
|
| 98 |
+
{%- endif %}
|
| 99 |
+
{%- set reasoning_content = reasoning_content|trim %}
|
| 100 |
+
{%- if loop.index0 > ns.last_query_index %}
|
| 101 |
+
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
|
| 102 |
+
{%- else %}
|
| 103 |
+
{{- '<|im_start|>' + message.role + '\n' + content }}
|
| 104 |
+
{%- endif %}
|
| 105 |
+
{%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
|
| 106 |
+
{%- for tool_call in message.tool_calls %}
|
| 107 |
+
{%- if tool_call.function is defined %}
|
| 108 |
+
{%- set tool_call = tool_call.function %}
|
| 109 |
+
{%- endif %}
|
| 110 |
+
{%- if loop.first %}
|
| 111 |
+
{%- if content|trim %}
|
| 112 |
+
{{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 113 |
+
{%- else %}
|
| 114 |
+
{{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 115 |
+
{%- endif %}
|
| 116 |
+
{%- else %}
|
| 117 |
+
{{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
|
| 118 |
+
{%- endif %}
|
| 119 |
+
{%- if tool_call.arguments is defined %}
|
| 120 |
+
{%- for args_name, args_value in tool_call.arguments|items %}
|
| 121 |
+
{{- '<parameter=' + args_name + '>\n' }}
|
| 122 |
+
{%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
|
| 123 |
+
{{- args_value }}
|
| 124 |
+
{{- '\n</parameter>\n' }}
|
| 125 |
+
{%- endfor %}
|
| 126 |
+
{%- endif %}
|
| 127 |
+
{{- '</function>\n</tool_call>' }}
|
| 128 |
+
{%- endfor %}
|
| 129 |
+
{%- endif %}
|
| 130 |
+
{{- '<|im_end|>\n' }}
|
| 131 |
+
{%- elif message.role == "tool" %}
|
| 132 |
+
{%- if loop.previtem and loop.previtem.role != "tool" %}
|
| 133 |
+
{{- '<|im_start|>user' }}
|
| 134 |
+
{%- endif %}
|
| 135 |
+
{{- '\n<tool_response>\n' }}
|
| 136 |
+
{{- content }}
|
| 137 |
+
{{- '\n</tool_response>' }}
|
| 138 |
+
{%- if not loop.last and loop.nextitem.role != "tool" %}
|
| 139 |
+
{{- '<|im_end|>\n' }}
|
| 140 |
+
{%- elif loop.last %}
|
| 141 |
+
{{- '<|im_end|>\n' }}
|
| 142 |
+
{%- endif %}
|
| 143 |
+
{%- else %}
|
| 144 |
+
{{- raise_exception('Unexpected message role.') }}
|
| 145 |
+
{%- endif %}
|
| 146 |
+
{%- endfor %}
|
| 147 |
+
{%- if add_generation_prompt %}
|
| 148 |
+
{{- '<|im_start|>assistant\n' }}
|
| 149 |
+
{%- if enable_thinking is defined and enable_thinking is true %}
|
| 150 |
+
{{- '<think>\n' }}
|
| 151 |
+
{%- else %}
|
| 152 |
+
{{- '<think>\n\n</think>\n\n' }}
|
| 153 |
+
{%- endif %}
|
| 154 |
+
{%- endif %}
|
config.json
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"abstain_slot": 255,
|
| 3 |
+
"answer_token_id": 248082,
|
| 4 |
+
"architectures": [
|
| 5 |
+
"OpenThaiSystemOneForDecision"
|
| 6 |
+
],
|
| 7 |
+
"auto_map": {
|
| 8 |
+
"AutoConfig": "configuration.OpenThaiSystemOneConfig",
|
| 9 |
+
"AutoModel": "modeling.OpenThaiSystemOneForDecision"
|
| 10 |
+
},
|
| 11 |
+
"dtype": "bfloat16",
|
| 12 |
+
"head_bias": true,
|
| 13 |
+
"image_token_id": null,
|
| 14 |
+
"model_type": "openthai_systemone",
|
| 15 |
+
"n_slots": 256,
|
| 16 |
+
"n_temperatures": 3,
|
| 17 |
+
"pad_token_id": 248044,
|
| 18 |
+
"point_head": null,
|
| 19 |
+
"point_token_id": null,
|
| 20 |
+
"quantization_config": {
|
| 21 |
+
"_load_in_4bit": false,
|
| 22 |
+
"_load_in_8bit": true,
|
| 23 |
+
"bnb_4bit_compute_dtype": "float32",
|
| 24 |
+
"bnb_4bit_quant_storage": "uint8",
|
| 25 |
+
"bnb_4bit_quant_type": "fp4",
|
| 26 |
+
"bnb_4bit_use_double_quant": false,
|
| 27 |
+
"llm_int8_enable_fp32_cpu_offload": false,
|
| 28 |
+
"llm_int8_has_fp16_weight": false,
|
| 29 |
+
"llm_int8_skip_modules": [
|
| 30 |
+
"slot_head",
|
| 31 |
+
"embed_tokens"
|
| 32 |
+
],
|
| 33 |
+
"llm_int8_threshold": 6.0,
|
| 34 |
+
"load_in_4bit": false,
|
| 35 |
+
"load_in_8bit": true,
|
| 36 |
+
"quant_method": "bitsandbytes"
|
| 37 |
+
},
|
| 38 |
+
"text_config": {
|
| 39 |
+
"_name_or_path": "runs/cpt/latest",
|
| 40 |
+
"architectures": [
|
| 41 |
+
"Qwen3_5ForCausalLM"
|
| 42 |
+
],
|
| 43 |
+
"attention_bias": false,
|
| 44 |
+
"attention_dropout": 0.0,
|
| 45 |
+
"attn_output_gate": true,
|
| 46 |
+
"bos_token_id": null,
|
| 47 |
+
"chunk_size_feed_forward": 0,
|
| 48 |
+
"dtype": "bfloat16",
|
| 49 |
+
"eos_token_id": 248044,
|
| 50 |
+
"full_attention_interval": 4,
|
| 51 |
+
"head_dim": 256,
|
| 52 |
+
"hidden_act": "silu",
|
| 53 |
+
"hidden_size": 1024,
|
| 54 |
+
"id2label": {
|
| 55 |
+
"0": "LABEL_0",
|
| 56 |
+
"1": "LABEL_1"
|
| 57 |
+
},
|
| 58 |
+
"initializer_range": 0.02,
|
| 59 |
+
"intermediate_size": 3584,
|
| 60 |
+
"is_encoder_decoder": false,
|
| 61 |
+
"label2id": {
|
| 62 |
+
"LABEL_0": 0,
|
| 63 |
+
"LABEL_1": 1
|
| 64 |
+
},
|
| 65 |
+
"layer_types": [
|
| 66 |
+
"linear_attention",
|
| 67 |
+
"linear_attention",
|
| 68 |
+
"linear_attention",
|
| 69 |
+
"full_attention",
|
| 70 |
+
"linear_attention",
|
| 71 |
+
"linear_attention",
|
| 72 |
+
"linear_attention",
|
| 73 |
+
"full_attention",
|
| 74 |
+
"linear_attention",
|
| 75 |
+
"linear_attention",
|
| 76 |
+
"linear_attention",
|
| 77 |
+
"full_attention",
|
| 78 |
+
"linear_attention",
|
| 79 |
+
"linear_attention",
|
| 80 |
+
"linear_attention",
|
| 81 |
+
"full_attention",
|
| 82 |
+
"linear_attention",
|
| 83 |
+
"linear_attention",
|
| 84 |
+
"linear_attention",
|
| 85 |
+
"full_attention",
|
| 86 |
+
"linear_attention",
|
| 87 |
+
"linear_attention",
|
| 88 |
+
"linear_attention",
|
| 89 |
+
"full_attention"
|
| 90 |
+
],
|
| 91 |
+
"linear_conv_kernel_dim": 4,
|
| 92 |
+
"linear_key_head_dim": 128,
|
| 93 |
+
"linear_num_key_heads": 16,
|
| 94 |
+
"linear_num_value_heads": 16,
|
| 95 |
+
"linear_value_head_dim": 128,
|
| 96 |
+
"mamba_ssm_dtype": "float32",
|
| 97 |
+
"max_position_embeddings": 262144,
|
| 98 |
+
"mlp_only_layers": [],
|
| 99 |
+
"model_type": "qwen3_5_text",
|
| 100 |
+
"mtp_num_hidden_layers": 1,
|
| 101 |
+
"mtp_use_dedicated_embeddings": false,
|
| 102 |
+
"num_attention_heads": 8,
|
| 103 |
+
"num_hidden_layers": 24,
|
| 104 |
+
"num_key_value_heads": 2,
|
| 105 |
+
"output_attentions": false,
|
| 106 |
+
"output_hidden_states": false,
|
| 107 |
+
"pad_token_id": null,
|
| 108 |
+
"partial_rotary_factor": 0.25,
|
| 109 |
+
"problem_type": null,
|
| 110 |
+
"return_dict": true,
|
| 111 |
+
"rms_norm_eps": 1e-06,
|
| 112 |
+
"rope_parameters": {
|
| 113 |
+
"mrope_interleaved": true,
|
| 114 |
+
"mrope_section": [
|
| 115 |
+
11,
|
| 116 |
+
11,
|
| 117 |
+
10
|
| 118 |
+
],
|
| 119 |
+
"partial_rotary_factor": 0.25,
|
| 120 |
+
"rope_theta": 10000000,
|
| 121 |
+
"rope_type": "default"
|
| 122 |
+
},
|
| 123 |
+
"tie_word_embeddings": false,
|
| 124 |
+
"use_cache": true,
|
| 125 |
+
"vocab_size": 248339
|
| 126 |
+
},
|
| 127 |
+
"transformers_version": "5.14.1",
|
| 128 |
+
"vision_config": null,
|
| 129 |
+
"vision_end_token_id": null,
|
| 130 |
+
"vision_start_token_id": null
|
| 131 |
+
}
|
configuration.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) + slot-head settings [+ vision tower + point head for the -Vision variant].
|
| 10 |
+
|
| 11 |
+
vision_config is None for the text-only line (v0.x): the model then wraps `Qwen3_5TextModel` and its state-dict
|
| 12 |
+
layout is unchanged. With a vision_config the model wraps the multimodal `Qwen3_5Model` (visual + language_model)
|
| 13 |
+
and gains a PointHead for `point` questions.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
model_type = "openthai_systemone"
|
| 17 |
+
sub_configs = {"text_config": AutoConfig, "vision_config": AutoConfig}
|
| 18 |
+
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
text_config: Optional[Dict[str, Any] | PretrainedConfig] = None,
|
| 22 |
+
n_slots: int = 256,
|
| 23 |
+
abstain_slot: int = 255,
|
| 24 |
+
answer_token_id: Optional[int] = None,
|
| 25 |
+
head_bias: bool = True,
|
| 26 |
+
n_temperatures: int = 3, # per question type: choice / score / noul (/ point)
|
| 27 |
+
vision_config: Optional[Dict[str, Any] | PretrainedConfig] = None,
|
| 28 |
+
image_token_id: Optional[int] = None,
|
| 29 |
+
vision_start_token_id: Optional[int] = None,
|
| 30 |
+
vision_end_token_id: Optional[int] = None,
|
| 31 |
+
point_token_id: Optional[int] = None,
|
| 32 |
+
point_head: Optional[Dict[str, Any]] = None, # {"dim": 256, "n_sa_layers": 1, "n_heads": 8}
|
| 33 |
+
**kwargs,
|
| 34 |
+
):
|
| 35 |
+
if isinstance(text_config, dict):
|
| 36 |
+
text_config = AutoConfig.for_model(**text_config) if "model_type" in text_config else AutoConfig.for_model("qwen3_5_text", **text_config)
|
| 37 |
+
if isinstance(vision_config, dict):
|
| 38 |
+
vc = dict(vision_config)
|
| 39 |
+
vision_config = AutoConfig.for_model(**vc) if "model_type" in vc else AutoConfig.for_model("qwen3_5_vision", **vc)
|
| 40 |
+
self.text_config = text_config
|
| 41 |
+
self.vision_config = vision_config
|
| 42 |
+
self.n_slots = n_slots
|
| 43 |
+
self.abstain_slot = abstain_slot
|
| 44 |
+
self.answer_token_id = answer_token_id
|
| 45 |
+
self.head_bias = head_bias
|
| 46 |
+
self.n_temperatures = n_temperatures
|
| 47 |
+
self.image_token_id = image_token_id
|
| 48 |
+
self.vision_start_token_id = vision_start_token_id
|
| 49 |
+
self.vision_end_token_id = vision_end_token_id
|
| 50 |
+
self.point_token_id = point_token_id
|
| 51 |
+
self.point_head = point_head
|
| 52 |
+
super().__init__(**kwargs)
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def is_vision(self) -> bool:
|
| 56 |
+
return self.vision_config is not None
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def hidden_size(self) -> int:
|
| 60 |
+
return self.text_config.hidden_size
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def vocab_size(self) -> int:
|
| 64 |
+
return self.text_config.vocab_size
|
formatting.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
Vision variant (OpenThai-SystemOne-Vision) adds, before the state (or inline where the state text says <image:id>):
|
| 21 |
+
|
| 22 |
+
<|ts_img|> [image:screen 1280x800 screenshot]
|
| 23 |
+
<|vision_start|><|image_pad|> x N <|vision_end|>
|
| 24 |
+
|
| 25 |
+
and a fourth question type whose readout is the PointHead over that image's N visual tokens:
|
| 26 |
+
|
| 27 |
+
<|ts_q|><|ts_point|> [image:screen] {instructions}
|
| 28 |
+
<|ts_answer|>
|
| 29 |
+
"""
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import json
|
| 33 |
+
import random
|
| 34 |
+
from dataclasses import dataclass, field
|
| 35 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
| 36 |
+
|
| 37 |
+
from .types import Choice, Noul, Point, Score, Question, MAX_OPTIONS
|
| 38 |
+
|
| 39 |
+
N_SLOTS = 256
|
| 40 |
+
ABSTAIN_SLOT = 255
|
| 41 |
+
|
| 42 |
+
TOK_STATE = "<|ts_state|>"
|
| 43 |
+
TOK_Q = "<|ts_q|>"
|
| 44 |
+
TOK_CHOICE = "<|ts_choice|>"
|
| 45 |
+
TOK_SCORE = "<|ts_score|>"
|
| 46 |
+
TOK_NOUL = "<|ts_noul|>"
|
| 47 |
+
TOK_ANSWER = "<|ts_answer|>"
|
| 48 |
+
TOK_OPT = [f"<|ts_opt_{i}|>" for i in range(N_SLOTS)]
|
| 49 |
+
TOK_POINT = "<|ts_point|>" # vision variant only
|
| 50 |
+
TOK_IMG = "<|ts_img|>" # vision variant only
|
| 51 |
+
|
| 52 |
+
SPECIAL_TOKENS: List[str] = [TOK_STATE, TOK_Q, TOK_CHOICE, TOK_SCORE, TOK_NOUL, TOK_ANSWER] + TOK_OPT
|
| 53 |
+
VISION_TOKENS: List[str] = [TOK_POINT, TOK_IMG] # added on top of SPECIAL_TOKENS for the -Vision variant
|
| 54 |
+
# Qwen's own multimodal tokens (already in the base tokenizer)
|
| 55 |
+
QWEN_VISION_START, QWEN_VISION_END, QWEN_IMAGE_PAD = "<|vision_start|>", "<|vision_end|>", "<|image_pad|>"
|
| 56 |
+
QTYPES = ("choice", "score", "noul", "point")
|
| 57 |
+
|
| 58 |
+
NOUL_OPTIONS = ("no", "yes") # slot 0 = no, slot 1 = yes -> noul = p(slot 1)
|
| 59 |
+
|
| 60 |
+
DEFAULT_MAX_TOTAL_TOKENS = 65536
|
| 61 |
+
DEFAULT_MAX_STATE_TOKENS = 32768
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def add_special_tokens(tokenizer, *, vision: bool = False) -> int:
|
| 65 |
+
"""Register the control tokens (plus the vision ones when vision=True). Returns number of tokens added."""
|
| 66 |
+
existing = set(tokenizer.get_vocab())
|
| 67 |
+
wanted = SPECIAL_TOKENS + (VISION_TOKENS if vision else [])
|
| 68 |
+
new = [t for t in wanted if t not in existing]
|
| 69 |
+
if not new:
|
| 70 |
+
return 0
|
| 71 |
+
return tokenizer.add_tokens(new, special_tokens=True)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def sanitize(text: str) -> str:
|
| 75 |
+
"""Stop user content from smuggling control tokens into the sequence."""
|
| 76 |
+
return text.replace("<|ts_", "<|ts_") if "<|ts_" in text else text
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def state_to_text(state: Union[str, Dict[str, Any], List[Any]], *, indent: Optional[int] = None) -> str:
|
| 80 |
+
if isinstance(state, str):
|
| 81 |
+
return state
|
| 82 |
+
return json.dumps(state, ensure_ascii=False, indent=indent)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class QuestionSpec:
|
| 87 |
+
"""A question flattened to option strings + slot bookkeeping."""
|
| 88 |
+
|
| 89 |
+
qid: str
|
| 90 |
+
qtype: str # choice | score | noul
|
| 91 |
+
instructions: str
|
| 92 |
+
option_names: List[str] # in slot order (after any permutation)
|
| 93 |
+
option_descs: List[Optional[str]]
|
| 94 |
+
perm: List[int] # perm[slot] = original index of the option at that slot
|
| 95 |
+
label_slot: Optional[int] = None # training only
|
| 96 |
+
image: Optional[str] = None # point questions: id of the referenced image (None = first image)
|
| 97 |
+
point_bbox: Optional[List[float]] = None # training only: normalised [x0,y0,x1,y1]; None with point_label_given -> null
|
| 98 |
+
point_label_given: bool = False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def question_to_spec(
|
| 102 |
+
qid: str,
|
| 103 |
+
q: Question,
|
| 104 |
+
*,
|
| 105 |
+
label: Optional[Union[str, int, bool]] = None,
|
| 106 |
+
shuffle: bool = False,
|
| 107 |
+
rng: Optional[random.Random] = None,
|
| 108 |
+
drop_label: bool = False,
|
| 109 |
+
perm: Optional[Sequence[int]] = None,
|
| 110 |
+
) -> QuestionSpec:
|
| 111 |
+
"""Flatten a typed question.
|
| 112 |
+
|
| 113 |
+
label: for training. Choice -> option name; Score -> level index (int); Noul -> bool.
|
| 114 |
+
shuffle: permute option order (Choice only; Score/Noul order is semantic).
|
| 115 |
+
drop_label: remove the correct option from a Choice so the target becomes ABSTAIN_SLOT.
|
| 116 |
+
perm: explicit option order for a Choice (list of original indices), e.g. a cyclic shift for order-invariant inference.
|
| 117 |
+
"""
|
| 118 |
+
if isinstance(q, Choice):
|
| 119 |
+
names = list(q.criteria.keys())
|
| 120 |
+
descs = [q.criteria[n] for n in names]
|
| 121 |
+
idx = list(range(len(names)))
|
| 122 |
+
label_idx = None
|
| 123 |
+
if label is not None:
|
| 124 |
+
if label not in q.criteria:
|
| 125 |
+
raise ValueError(f"label {label!r} is not one of the options")
|
| 126 |
+
label_idx = names.index(str(label))
|
| 127 |
+
if drop_label and label_idx is not None:
|
| 128 |
+
if len(idx) < 2:
|
| 129 |
+
raise ValueError("cannot drop the only option")
|
| 130 |
+
idx.remove(label_idx)
|
| 131 |
+
label_idx = None
|
| 132 |
+
if perm is not None:
|
| 133 |
+
idx = [i for i in perm if i in idx]
|
| 134 |
+
elif shuffle:
|
| 135 |
+
(rng or random).shuffle(idx)
|
| 136 |
+
names_p = [names[i] for i in idx]
|
| 137 |
+
descs_p = [descs[i] for i in idx]
|
| 138 |
+
if label is None:
|
| 139 |
+
slot = None
|
| 140 |
+
elif label_idx is None:
|
| 141 |
+
slot = ABSTAIN_SLOT
|
| 142 |
+
else:
|
| 143 |
+
slot = idx.index(label_idx)
|
| 144 |
+
return QuestionSpec(qid, "choice", q.instructions, names_p, descs_p, idx, slot)
|
| 145 |
+
|
| 146 |
+
if isinstance(q, Score):
|
| 147 |
+
names = [str(i) for i in range(len(q.criteria))]
|
| 148 |
+
descs = list(q.criteria)
|
| 149 |
+
slot = int(label) if label is not None else None
|
| 150 |
+
if slot is not None and not (0 <= slot < len(descs)):
|
| 151 |
+
raise ValueError("score label out of range")
|
| 152 |
+
return QuestionSpec(qid, "score", q.instructions, names, descs, list(range(len(names))), slot)
|
| 153 |
+
|
| 154 |
+
if isinstance(q, Noul):
|
| 155 |
+
c = q.criteria or {}
|
| 156 |
+
descs = [c.get("false"), c.get("true")]
|
| 157 |
+
slot = None if label is None else int(bool(label))
|
| 158 |
+
return QuestionSpec(qid, "noul", q.instructions, list(NOUL_OPTIONS), descs, [0, 1], slot)
|
| 159 |
+
|
| 160 |
+
if isinstance(q, Point):
|
| 161 |
+
# label: {"bbox": [x0,y0,x1,y1]} | [x0,y0,x1,y1] | {"bbox": None} (= not on screen) | None (inference)
|
| 162 |
+
bbox, given = None, False
|
| 163 |
+
if label is not None:
|
| 164 |
+
given = True
|
| 165 |
+
bbox = label.get("bbox") if isinstance(label, dict) else list(label)
|
| 166 |
+
if bbox is not None:
|
| 167 |
+
bbox = [float(v) for v in bbox]
|
| 168 |
+
if len(bbox) != 4:
|
| 169 |
+
raise ValueError("point label bbox needs 4 numbers")
|
| 170 |
+
return QuestionSpec(qid, "point", q.instructions, [], [], [], None, image=q.image, point_bbox=bbox, point_label_given=given)
|
| 171 |
+
|
| 172 |
+
raise TypeError(type(q))
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def spec_to_text(spec: QuestionSpec, *, image_id: Optional[str] = None) -> str:
|
| 176 |
+
if spec.qtype == "point":
|
| 177 |
+
tag = f"[image:{sanitize(str(image_id if image_id is not None else spec.image or ''))}] "
|
| 178 |
+
return f"{TOK_Q}{TOK_POINT} {tag}{sanitize(spec.instructions).strip()}\n{TOK_ANSWER}\n"
|
| 179 |
+
head = {"choice": TOK_CHOICE, "score": TOK_SCORE, "noul": TOK_NOUL}[spec.qtype]
|
| 180 |
+
lines = [f"{TOK_Q}{head} {sanitize(spec.instructions).strip()}"]
|
| 181 |
+
for i, (name, desc) in enumerate(zip(spec.option_names, spec.option_descs)):
|
| 182 |
+
name = sanitize(str(name)).strip()
|
| 183 |
+
if desc:
|
| 184 |
+
lines.append(f"{TOK_OPT[i]} {name}: {sanitize(str(desc)).strip()}")
|
| 185 |
+
else:
|
| 186 |
+
lines.append(f"{TOK_OPT[i]} {name}")
|
| 187 |
+
lines.append(TOK_ANSWER)
|
| 188 |
+
return "\n".join(lines) + "\n"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@dataclass
|
| 192 |
+
class Encoded:
|
| 193 |
+
input_ids: List[int]
|
| 194 |
+
answer_positions: List[int] # index of each <|ts_answer|> token, question order
|
| 195 |
+
option_counts: List[int] # k per question (valid slots 0..k-1)
|
| 196 |
+
specs: List[QuestionSpec]
|
| 197 |
+
labels: List[int] = field(default_factory=list) # -100 if unknown
|
| 198 |
+
truncated_state: bool = False
|
| 199 |
+
# vision variant
|
| 200 |
+
image_ids: List[str] = field(default_factory=list)
|
| 201 |
+
image_grid_thw: List[List[int]] = field(default_factory=list) # per image (t, h, w) in patches
|
| 202 |
+
image_spans: List[Tuple[int, int]] = field(default_factory=list) # [start, end) positions of each image's tokens
|
| 203 |
+
image_sizes: List[Tuple[int, int]] = field(default_factory=list) # original (W, H)
|
| 204 |
+
pixel_values: Any = None # torch.Tensor (sum patches, C*T*P*P) or None
|
| 205 |
+
merge_size: int = 2
|
| 206 |
+
point_image_index: List[int] = field(default_factory=list) # per question: image index or -1
|
| 207 |
+
point_targets: List[Any] = field(default_factory=list) # per question: tensor (n_tokens+1) or None
|
| 208 |
+
|
| 209 |
+
@property
|
| 210 |
+
def n_tokens(self) -> int:
|
| 211 |
+
return len(self.input_ids)
|
| 212 |
+
|
| 213 |
+
@property
|
| 214 |
+
def n_visual_tokens(self) -> int:
|
| 215 |
+
return sum(e - s for s, e in self.image_spans)
|
| 216 |
+
|
| 217 |
+
def merged_grid(self, i: int) -> Tuple[int, int]:
|
| 218 |
+
t, h, w = self.image_grid_thw[i]
|
| 219 |
+
return h // self.merge_size, w // self.merge_size
|
| 220 |
+
|
| 221 |
+
def n_tokens_of_image(self, i: int) -> int:
|
| 222 |
+
s, e = self.image_spans[i]
|
| 223 |
+
return e - s
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class Formatter:
|
| 227 |
+
"""Tokenizer-aware encoder shared by training and inference."""
|
| 228 |
+
|
| 229 |
+
def __init__(
|
| 230 |
+
self,
|
| 231 |
+
tokenizer,
|
| 232 |
+
*,
|
| 233 |
+
max_total_tokens: int = DEFAULT_MAX_TOTAL_TOKENS,
|
| 234 |
+
max_state_tokens: int = DEFAULT_MAX_STATE_TOKENS,
|
| 235 |
+
image_processor=None,
|
| 236 |
+
max_pixels: Optional[int] = None,
|
| 237 |
+
):
|
| 238 |
+
self.tok = tokenizer
|
| 239 |
+
self.image_processor = image_processor
|
| 240 |
+
self.vision = image_processor is not None
|
| 241 |
+
add_special_tokens(self.tok, vision=self.vision)
|
| 242 |
+
self.max_total_tokens = max_total_tokens
|
| 243 |
+
self.max_state_tokens = max_state_tokens
|
| 244 |
+
self.max_pixels = max_pixels
|
| 245 |
+
self.image_device = None # set by the client: patchify maths runs on the model device
|
| 246 |
+
self.answer_id = self.tok.convert_tokens_to_ids(TOK_ANSWER)
|
| 247 |
+
self.state_id = self.tok.convert_tokens_to_ids(TOK_STATE)
|
| 248 |
+
self.opt_ids = self.tok.convert_tokens_to_ids(TOK_OPT)
|
| 249 |
+
assert self.answer_id is not None and self.answer_id != self.tok.unk_token_id
|
| 250 |
+
if self.vision:
|
| 251 |
+
self.image_pad_id = self.tok.convert_tokens_to_ids(QWEN_IMAGE_PAD)
|
| 252 |
+
self.vision_start_id = self.tok.convert_tokens_to_ids(QWEN_VISION_START)
|
| 253 |
+
self.vision_end_id = self.tok.convert_tokens_to_ids(QWEN_VISION_END)
|
| 254 |
+
self.point_id = self.tok.convert_tokens_to_ids(TOK_POINT)
|
| 255 |
+
for v in (self.image_pad_id, self.vision_start_id, self.vision_end_id, self.point_id):
|
| 256 |
+
assert v is not None and v != self.tok.unk_token_id, "tokenizer lacks the Qwen vision tokens"
|
| 257 |
+
|
| 258 |
+
def _image_block(self, ref, n_tokens: int, size: Tuple[int, int]) -> List[int]:
|
| 259 |
+
"""<|ts_img|> [image:id WxH role]\n<|vision_start|> pad*n <|vision_end|>\n -> ids; the pad run is contiguous."""
|
| 260 |
+
role = f" {sanitize(str(ref.role))}" if getattr(ref, "role", None) else ""
|
| 261 |
+
head = self._ids(f"{TOK_IMG} [image:{sanitize(ref.id)} {size[0]}x{size[1]}{role}]\n")
|
| 262 |
+
return head + [self.vision_start_id] + [self.image_pad_id] * n_tokens + [self.vision_end_id] + self._ids("\n")
|
| 263 |
+
|
| 264 |
+
def _ids(self, text: str) -> List[int]:
|
| 265 |
+
return self.tok(text, add_special_tokens=False)["input_ids"]
|
| 266 |
+
|
| 267 |
+
def encode(
|
| 268 |
+
self,
|
| 269 |
+
state: Union[str, Dict[str, Any], List[Any]],
|
| 270 |
+
questions: Dict[str, Question],
|
| 271 |
+
*,
|
| 272 |
+
labels: Optional[Dict[str, Union[str, int, bool]]] = None,
|
| 273 |
+
shuffle_options: bool = False,
|
| 274 |
+
shuffle_questions: bool = False,
|
| 275 |
+
drop_label_for: Optional[Sequence[str]] = None,
|
| 276 |
+
rng: Optional[random.Random] = None,
|
| 277 |
+
state_indent: Optional[int] = None,
|
| 278 |
+
option_orders: Optional[Dict[str, Sequence[int]]] = None,
|
| 279 |
+
images: Optional[Sequence[Any]] = None,
|
| 280 |
+
processed_images=None,
|
| 281 |
+
) -> Encoded:
|
| 282 |
+
"""images: list of ImageRef / dicts (vision variant). processed_images: a ProcessedImages to reuse (e.g. across
|
| 283 |
+
the option-order permutations of one request) instead of running the image processor again."""
|
| 284 |
+
rng = rng or random.Random()
|
| 285 |
+
labels = labels or {}
|
| 286 |
+
drop = set(drop_label_for or [])
|
| 287 |
+
option_orders = option_orders or {}
|
| 288 |
+
qids = list(questions.keys())
|
| 289 |
+
if shuffle_questions:
|
| 290 |
+
rng.shuffle(qids)
|
| 291 |
+
|
| 292 |
+
# ---- images (vision variant)
|
| 293 |
+
proc = processed_images
|
| 294 |
+
refs: List[Any] = []
|
| 295 |
+
if images:
|
| 296 |
+
if not self.vision:
|
| 297 |
+
raise ValueError("this model is text-only; images and point questions need OpenThai-SystemOne-Vision")
|
| 298 |
+
from .images import ImageRef, process_images
|
| 299 |
+
|
| 300 |
+
refs = [ImageRef.parse(im) for im in images]
|
| 301 |
+
if proc is None:
|
| 302 |
+
kw = {"max_pixels": self.max_pixels} if self.max_pixels else {}
|
| 303 |
+
proc = process_images(self.image_processor, refs, device=self.image_device, **kw)
|
| 304 |
+
has_point = any(isinstance(questions[q], Point) for q in qids)
|
| 305 |
+
if has_point and not refs:
|
| 306 |
+
raise ValueError("point questions need at least one image")
|
| 307 |
+
image_index = {r.id: i for i, r in enumerate(refs)}
|
| 308 |
+
|
| 309 |
+
specs = [
|
| 310 |
+
question_to_spec(
|
| 311 |
+
qid,
|
| 312 |
+
questions[qid],
|
| 313 |
+
label=labels.get(qid),
|
| 314 |
+
shuffle=shuffle_options,
|
| 315 |
+
rng=rng,
|
| 316 |
+
drop_label=qid in drop,
|
| 317 |
+
perm=option_orders.get(qid),
|
| 318 |
+
)
|
| 319 |
+
for qid in qids
|
| 320 |
+
]
|
| 321 |
+
point_image_index: List[int] = []
|
| 322 |
+
for sp in specs:
|
| 323 |
+
if sp.qtype != "point":
|
| 324 |
+
point_image_index.append(-1)
|
| 325 |
+
continue
|
| 326 |
+
if sp.image is None:
|
| 327 |
+
point_image_index.append(0)
|
| 328 |
+
elif sp.image in image_index:
|
| 329 |
+
point_image_index.append(image_index[sp.image])
|
| 330 |
+
else:
|
| 331 |
+
raise ValueError(f"point question {sp.qid!r} references unknown image {sp.image!r}")
|
| 332 |
+
q_texts = [spec_to_text(s, image_id=(refs[point_image_index[i]].id if s.qtype == "point" else None)) for i, s in enumerate(specs)]
|
| 333 |
+
q_ids = [self._ids(t) for t in q_texts]
|
| 334 |
+
q_total = sum(len(x) for x in q_ids)
|
| 335 |
+
|
| 336 |
+
# image blocks: inline where the state text says <image:id>, otherwise all before the state
|
| 337 |
+
blocks: Dict[str, List[int]] = {}
|
| 338 |
+
if refs:
|
| 339 |
+
for i, r in enumerate(refs):
|
| 340 |
+
blocks[r.id] = self._image_block(r, proc.n_tokens[i], proc.sizes[i])
|
| 341 |
+
state_text = sanitize(state_to_text(state, indent=state_indent)).strip()
|
| 342 |
+
inline = [r.id for r in refs if f"<image:{r.id}>" in state_text]
|
| 343 |
+
prefix_ids: List[int] = []
|
| 344 |
+
for r in refs:
|
| 345 |
+
if r.id not in inline:
|
| 346 |
+
prefix_ids += blocks[r.id]
|
| 347 |
+
body_parts: List[List[int]] = []
|
| 348 |
+
if inline:
|
| 349 |
+
import re
|
| 350 |
+
|
| 351 |
+
pieces = re.split("(" + "|".join(re.escape(f"<image:{i}>") for i in inline) + ")", state_text)
|
| 352 |
+
first = True
|
| 353 |
+
for piece in pieces:
|
| 354 |
+
if piece.startswith("<image:") and piece[7:-1] in blocks:
|
| 355 |
+
body_parts.append(self._ids("\n") + blocks[piece[7:-1]])
|
| 356 |
+
elif piece:
|
| 357 |
+
body_parts.append(self._ids((TOK_STATE + " " if first else "") + piece))
|
| 358 |
+
first = False
|
| 359 |
+
if first:
|
| 360 |
+
body_parts.insert(0, self._ids(TOK_STATE + " "))
|
| 361 |
+
state_ids = [t for part in body_parts for t in part] + self._ids("\n")
|
| 362 |
+
else:
|
| 363 |
+
state_ids = self._ids(TOK_STATE + " " + state_text + "\n")
|
| 364 |
+
budget = min(self.max_state_tokens, self.max_total_tokens - q_total - len(prefix_ids))
|
| 365 |
+
truncated = False
|
| 366 |
+
if len(state_ids) > budget and not inline:
|
| 367 |
+
# keep the head (state token) and the tail of the state; the end is usually the most recent info
|
| 368 |
+
keep_tail = max(budget - 1, 0)
|
| 369 |
+
state_ids = state_ids[:1] + state_ids[len(state_ids) - keep_tail :]
|
| 370 |
+
truncated = True
|
| 371 |
+
|
| 372 |
+
ids: List[int] = prefix_ids + list(state_ids)
|
| 373 |
+
# locate each image's contiguous pad run (in `refs` order = the order the blocks were emitted)
|
| 374 |
+
image_spans: List[Tuple[int, int]] = []
|
| 375 |
+
if refs:
|
| 376 |
+
runs = []
|
| 377 |
+
i = 0
|
| 378 |
+
while i < len(ids):
|
| 379 |
+
if ids[i] == self.image_pad_id:
|
| 380 |
+
j = i
|
| 381 |
+
while j < len(ids) and ids[j] == self.image_pad_id:
|
| 382 |
+
j += 1
|
| 383 |
+
runs.append((i, j))
|
| 384 |
+
i = j
|
| 385 |
+
else:
|
| 386 |
+
i += 1
|
| 387 |
+
# runs appear in emission order: prefix blocks first (refs order minus inline), then inline ones in text order
|
| 388 |
+
order = [r.id for r in refs if r.id not in inline] + [m for m in re.findall(r"<image:([^>]+)>", state_text) if m in inline] if inline else [r.id for r in refs]
|
| 389 |
+
by_id = dict(zip(order, runs))
|
| 390 |
+
image_spans = [by_id[r.id] for r in refs]
|
| 391 |
+
assert all(e - s == proc.n_tokens[i] for i, (s, e) in enumerate(image_spans))
|
| 392 |
+
answer_positions: List[int] = []
|
| 393 |
+
for qi in q_ids:
|
| 394 |
+
ids.extend(qi)
|
| 395 |
+
# the answer token is the last non-newline token of each question block
|
| 396 |
+
pos = len(ids) - 1
|
| 397 |
+
while ids[pos] != self.answer_id:
|
| 398 |
+
pos -= 1
|
| 399 |
+
answer_positions.append(pos)
|
| 400 |
+
|
| 401 |
+
point_targets: List[Any] = []
|
| 402 |
+
if refs:
|
| 403 |
+
from .images import bbox_to_token_target
|
| 404 |
+
|
| 405 |
+
for sp, ii in zip(specs, point_image_index):
|
| 406 |
+
if sp.qtype == "point" and sp.point_label_given:
|
| 407 |
+
t, h, w = proc.grid_thw[ii]
|
| 408 |
+
point_targets.append(bbox_to_token_target(sp.point_bbox, h // proc.merge, w // proc.merge))
|
| 409 |
+
else:
|
| 410 |
+
point_targets.append(None)
|
| 411 |
+
else:
|
| 412 |
+
point_targets = [None] * len(specs)
|
| 413 |
+
|
| 414 |
+
return Encoded(
|
| 415 |
+
input_ids=ids,
|
| 416 |
+
answer_positions=answer_positions,
|
| 417 |
+
option_counts=[len(s.option_names) for s in specs],
|
| 418 |
+
specs=specs,
|
| 419 |
+
labels=[(-100 if s.label_slot is None else s.label_slot) for s in specs],
|
| 420 |
+
truncated_state=truncated,
|
| 421 |
+
image_ids=[r.id for r in refs],
|
| 422 |
+
image_grid_thw=list(proc.grid_thw) if refs else [],
|
| 423 |
+
image_spans=image_spans,
|
| 424 |
+
image_sizes=list(proc.sizes) if refs else [],
|
| 425 |
+
pixel_values=proc.pixel_values if refs else None,
|
| 426 |
+
merge_size=proc.merge if refs else 2,
|
| 427 |
+
point_image_index=point_image_index,
|
| 428 |
+
point_targets=point_targets,
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def slot_mask(option_counts: Sequence[int], *, include_abstain: bool = True, n_slots: int = N_SLOTS):
|
| 433 |
+
"""Boolean mask (Q, n_slots): True where a slot is valid for that question."""
|
| 434 |
+
import torch
|
| 435 |
+
|
| 436 |
+
k = torch.as_tensor(list(option_counts), dtype=torch.long)
|
| 437 |
+
ar = torch.arange(n_slots)
|
| 438 |
+
mask = ar[None, :] < k[:, None]
|
| 439 |
+
if include_abstain:
|
| 440 |
+
mask[:, ABSTAIN_SLOT] = True
|
| 441 |
+
return mask
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def mrope_position_ids(encoded: Sequence[Encoded], T: int):
|
| 445 |
+
"""Qwen3.5 3-D (t, h, w) position ids for a right-padded batch, computed on CPU without per-token python loops.
|
| 446 |
+
|
| 447 |
+
Text tokens: t = h = w = running position. An image with merged grid (t, hm, wm) placed at running position p gets
|
| 448 |
+
t = p + frame index, h = p + row, w = p + col, and advances the running position by max(hm, wm). Equals
|
| 449 |
+
`Qwen3_5Model.get_rope_index` (tests/test_vision.py checks it) but costs ~0.1 ms instead of tens of ms.
|
| 450 |
+
"""
|
| 451 |
+
import torch
|
| 452 |
+
|
| 453 |
+
B = len(encoded)
|
| 454 |
+
pos = torch.zeros((3, B, T), dtype=torch.long)
|
| 455 |
+
for b, e in enumerate(encoded):
|
| 456 |
+
n = e.n_tokens
|
| 457 |
+
cur = 0 # running position
|
| 458 |
+
idx = 0 # token index
|
| 459 |
+
spans = sorted(zip(e.image_spans, range(len(e.image_spans))))
|
| 460 |
+
for (s0, e0), i in spans:
|
| 461 |
+
if s0 > idx: # text before the image
|
| 462 |
+
ar = torch.arange(s0 - idx) + cur
|
| 463 |
+
pos[:, b, idx:s0] = ar
|
| 464 |
+
cur += s0 - idx
|
| 465 |
+
t, h, w = e.image_grid_thw[i]
|
| 466 |
+
hm, wm = h // e.merge_size, w // e.merge_size
|
| 467 |
+
tt = torch.arange(t).view(t, 1, 1).expand(t, hm, wm)
|
| 468 |
+
hh = torch.arange(hm).view(1, hm, 1).expand(t, hm, wm)
|
| 469 |
+
ww = torch.arange(wm).view(1, 1, wm).expand(t, hm, wm)
|
| 470 |
+
pos[0, b, s0:e0] = tt.reshape(-1) + cur
|
| 471 |
+
pos[1, b, s0:e0] = hh.reshape(-1) + cur
|
| 472 |
+
pos[2, b, s0:e0] = ww.reshape(-1) + cur
|
| 473 |
+
cur += max(hm, wm)
|
| 474 |
+
idx = e0
|
| 475 |
+
if n > idx:
|
| 476 |
+
pos[:, b, idx:n] = torch.arange(n - idx) + cur
|
| 477 |
+
return pos
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def collate(encoded: Sequence[Encoded], pad_id: int, *, max_questions: Optional[int] = None):
|
| 481 |
+
"""Right-pad a batch. Returns dict of tensors for OpenThaiSystemOneForDecision.forward."""
|
| 482 |
+
import torch
|
| 483 |
+
|
| 484 |
+
B = len(encoded)
|
| 485 |
+
T = max(e.n_tokens for e in encoded)
|
| 486 |
+
Q = max_questions or max(len(e.answer_positions) for e in encoded)
|
| 487 |
+
input_ids = torch.full((B, T), pad_id, dtype=torch.long)
|
| 488 |
+
attention_mask = torch.zeros((B, T), dtype=torch.long)
|
| 489 |
+
answer_positions = torch.zeros((B, Q), dtype=torch.long)
|
| 490 |
+
option_counts = torch.zeros((B, Q), dtype=torch.long)
|
| 491 |
+
labels = torch.full((B, Q), -100, dtype=torch.long)
|
| 492 |
+
qtypes = torch.full((B, Q), -1, dtype=torch.long)
|
| 493 |
+
for b, e in enumerate(encoded):
|
| 494 |
+
n = e.n_tokens
|
| 495 |
+
input_ids[b, :n] = torch.tensor(e.input_ids)
|
| 496 |
+
attention_mask[b, :n] = 1
|
| 497 |
+
q = len(e.answer_positions)
|
| 498 |
+
answer_positions[b, :q] = torch.tensor(e.answer_positions)
|
| 499 |
+
option_counts[b, :q] = torch.tensor(e.option_counts)
|
| 500 |
+
qtypes[b, :q] = torch.tensor([QTYPES.index(s.qtype) for s in e.specs])
|
| 501 |
+
if e.labels:
|
| 502 |
+
labels[b, :q] = torch.tensor(e.labels)
|
| 503 |
+
out = {
|
| 504 |
+
"input_ids": input_ids,
|
| 505 |
+
"attention_mask": attention_mask,
|
| 506 |
+
"answer_positions": answer_positions,
|
| 507 |
+
"option_counts": option_counts,
|
| 508 |
+
"labels": labels,
|
| 509 |
+
"qtypes": qtypes,
|
| 510 |
+
}
|
| 511 |
+
if any(e.pixel_values is not None for e in encoded):
|
| 512 |
+
I = max(len(e.image_spans) for e in encoded)
|
| 513 |
+
pv = [e.pixel_values for e in encoded if e.pixel_values is not None]
|
| 514 |
+
grid = [torch.tensor(e.image_grid_thw, dtype=torch.long) for e in encoded if e.image_grid_thw]
|
| 515 |
+
image_spans = torch.full((B, I, 2), -1, dtype=torch.long)
|
| 516 |
+
mm_token_type_ids = torch.zeros((B, T), dtype=torch.long)
|
| 517 |
+
point_image_index = torch.full((B, Q), -1, dtype=torch.long)
|
| 518 |
+
L = max([e.n_tokens_of_image(i) for e in encoded for i in range(len(e.image_spans))] + [1])
|
| 519 |
+
point_targets = torch.zeros((B, Q, L + 1))
|
| 520 |
+
point_has_target = torch.zeros((B, Q), dtype=torch.bool)
|
| 521 |
+
for b, e in enumerate(encoded):
|
| 522 |
+
for i, (s0, e0) in enumerate(e.image_spans):
|
| 523 |
+
image_spans[b, i] = torch.tensor([s0, e0])
|
| 524 |
+
mm_token_type_ids[b, s0:e0] = 1
|
| 525 |
+
for qi, ii in enumerate(e.point_image_index):
|
| 526 |
+
point_image_index[b, qi] = ii
|
| 527 |
+
tgt = e.point_targets[qi] if qi < len(e.point_targets) else None
|
| 528 |
+
if tgt is not None:
|
| 529 |
+
n = tgt.shape[0] - 1
|
| 530 |
+
point_targets[b, qi, :n] = tgt[:n]
|
| 531 |
+
point_targets[b, qi, L] = tgt[n]
|
| 532 |
+
point_has_target[b, qi] = True
|
| 533 |
+
out.update({
|
| 534 |
+
"pixel_values": torch.cat(pv, 0),
|
| 535 |
+
"image_grid_thw": torch.cat(grid, 0),
|
| 536 |
+
"mm_token_type_ids": mm_token_type_ids,
|
| 537 |
+
"position_ids": mrope_position_ids(encoded, T),
|
| 538 |
+
"image_spans": image_spans,
|
| 539 |
+
"point_image_index": point_image_index,
|
| 540 |
+
"point_targets": point_targets,
|
| 541 |
+
"point_has_target": point_has_target,
|
| 542 |
+
})
|
| 543 |
+
return out
|
images.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image inputs for the vision variant: loading, resizing to the ViT token budget, and the patch-grid geometry
|
| 2 |
+
that the `point` head works in.
|
| 3 |
+
|
| 4 |
+
Geometry: the vision tower emits one token per (patch_size * spatial_merge_size)^2 pixel block (32x32 px for
|
| 5 |
+
Qwen3.5), row-major over the merged grid (hm, wm) = (h / merge, w / merge) where (t, h, w) = image_grid_thw.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
import io
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
| 13 |
+
|
| 14 |
+
DEFAULT_MAX_PIXELS = 1280 * 800 # ~1000 visual tokens at 32 px/token
|
| 15 |
+
HIGH_DETAIL_MAX_PIXELS = 1920 * 1200 # ~2280 tokens
|
| 16 |
+
DEFAULT_MIN_PIXELS = 64 * 64
|
| 17 |
+
MAX_IMAGES_PER_REQUEST = 4
|
| 18 |
+
MAX_VISUAL_TOKENS = 8192
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_image(src: Any):
|
| 22 |
+
"""PIL image from: PIL.Image, bytes, data-URI / base64 string, http(s) URL, or a filesystem path."""
|
| 23 |
+
from PIL import Image
|
| 24 |
+
|
| 25 |
+
if isinstance(src, Image.Image):
|
| 26 |
+
return src.convert("RGB")
|
| 27 |
+
if isinstance(src, (bytes, bytearray)):
|
| 28 |
+
return Image.open(io.BytesIO(src)).convert("RGB")
|
| 29 |
+
if hasattr(src, "shape"): # numpy array (H, W, 3)
|
| 30 |
+
return Image.fromarray(src).convert("RGB")
|
| 31 |
+
if not isinstance(src, str):
|
| 32 |
+
raise TypeError(f"unsupported image source {type(src)}")
|
| 33 |
+
if src.startswith("data:"):
|
| 34 |
+
_, _, payload = src.partition(",")
|
| 35 |
+
return Image.open(io.BytesIO(base64.b64decode(payload))).convert("RGB")
|
| 36 |
+
if src.startswith(("http://", "https://")):
|
| 37 |
+
import urllib.request
|
| 38 |
+
|
| 39 |
+
req = urllib.request.Request(src, headers={"User-Agent": "openthai-systemone/1.0"})
|
| 40 |
+
with urllib.request.urlopen(req, timeout=20) as r:
|
| 41 |
+
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
| 42 |
+
if len(src) > 512 and "/" not in src[:16] and not src.startswith("."):
|
| 43 |
+
try: # bare base64
|
| 44 |
+
return Image.open(io.BytesIO(base64.b64decode(src, validate=True))).convert("RGB")
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
return Image.open(src).convert("RGB")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class ImageRef:
|
| 52 |
+
"""One image attached to a request."""
|
| 53 |
+
|
| 54 |
+
id: str
|
| 55 |
+
image: Any # anything load_image accepts
|
| 56 |
+
detail: str = "auto" # auto | high
|
| 57 |
+
role: Optional[str] = None # free text tag, shown in the sequence (screenshot, frame, photo, document)
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def parse(cls, obj: Union["ImageRef", Dict[str, Any]]) -> "ImageRef":
|
| 61 |
+
if isinstance(obj, ImageRef):
|
| 62 |
+
return obj
|
| 63 |
+
d = dict(obj)
|
| 64 |
+
src = d.get("image", d.get("data", d.get("url", d.get("path"))))
|
| 65 |
+
if src is None:
|
| 66 |
+
raise ValueError("image needs one of: image / data / url / path")
|
| 67 |
+
return cls(id=str(d.get("id", "img")), image=src, detail=str(d.get("detail", "auto")), role=d.get("role"))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class ProcessedImages:
|
| 72 |
+
ids: List[str]
|
| 73 |
+
pixel_values: Any # torch.Tensor (sum_patches, C*T*P*P)
|
| 74 |
+
grid_thw: List[List[int]] # per image (t, h, w) in patches
|
| 75 |
+
sizes: List[Tuple[int, int]] # original (W, H)
|
| 76 |
+
n_tokens: List[int] = field(default_factory=list)
|
| 77 |
+
merge: int = 2
|
| 78 |
+
|
| 79 |
+
def tokens_for(self, i: int) -> int:
|
| 80 |
+
t, h, w = self.grid_thw[i]
|
| 81 |
+
return t * h * w // (self.merge * self.merge)
|
| 82 |
+
|
| 83 |
+
def merged_grid(self, i: int) -> Tuple[int, int]:
|
| 84 |
+
t, h, w = self.grid_thw[i]
|
| 85 |
+
return h // self.merge, w // self.merge
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def fast_patchify(im, image_processor, *, min_pixels: int, max_pixels: int, device=None):
|
| 89 |
+
"""Qwen2-VL preprocessing without the HF processor's overhead (~3 ms vs ~40 ms per frame on a busy CPU).
|
| 90 |
+
|
| 91 |
+
Same maths: smart_resize to multiples of patch*merge within [min_pixels, max_pixels], bicubic resize, rescale,
|
| 92 |
+
normalise with the processor's mean/std, duplicate the frame temporal_patch_size times, and flatten patches in the
|
| 93 |
+
processor's (t, h/merge, w/merge, merge, merge, C, T, p, p) order. tests/test_vision.py checks equality with the HF
|
| 94 |
+
processor output.
|
| 95 |
+
"""
|
| 96 |
+
import numpy as np
|
| 97 |
+
import torch
|
| 98 |
+
from PIL import Image
|
| 99 |
+
from transformers.models.qwen2_vl.image_processing_qwen2_vl import smart_resize
|
| 100 |
+
|
| 101 |
+
patch = int(getattr(image_processor, "patch_size", 16))
|
| 102 |
+
merge = int(getattr(image_processor, "merge_size", 2))
|
| 103 |
+
tp = int(getattr(image_processor, "temporal_patch_size", 2))
|
| 104 |
+
mean = torch.tensor(image_processor.image_mean, dtype=torch.float32).view(3, 1, 1)
|
| 105 |
+
std = torch.tensor(image_processor.image_std, dtype=torch.float32).view(3, 1, 1)
|
| 106 |
+
w, h = im.size
|
| 107 |
+
rh, rw = smart_resize(h, w, factor=patch * merge, min_pixels=min_pixels, max_pixels=max_pixels)
|
| 108 |
+
if (rw, rh) != (w, h):
|
| 109 |
+
im = im.resize((rw, rh), Image.BICUBIC)
|
| 110 |
+
x = torch.from_numpy(np.asarray(im, dtype=np.uint8))
|
| 111 |
+
if device is not None: # normalise + patchify on the GPU: the CPU only resizes and uploads 3 bytes/pixel
|
| 112 |
+
x = x.to(device, non_blocking=True)
|
| 113 |
+
mean, std = mean.to(device), std.to(device)
|
| 114 |
+
x = x.permute(2, 0, 1).float() / 255.0 # (C, H, W)
|
| 115 |
+
x = (x - mean) / std
|
| 116 |
+
gh, gw = rh // patch, rw // patch
|
| 117 |
+
x = x.unsqueeze(0).expand(tp, -1, -1, -1) # (T, C, H, W)
|
| 118 |
+
x = x.reshape(tp, 3, gh // merge, merge, patch, gw // merge, merge, patch)
|
| 119 |
+
# HF order: (grid_t, temporal, C, gh//m, m, p, gw//m, m, p) -> transpose(0, 3, 6, 4, 7, 2, 1, 5, 8)
|
| 120 |
+
x = x.unsqueeze(0) # (1, T, C, gh/m, m, p, gw/m, m, p)
|
| 121 |
+
x = x.permute(0, 3, 6, 4, 7, 2, 1, 5, 8).reshape(gh * gw, 3 * tp * patch * patch)
|
| 122 |
+
return x, [1, gh, gw]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def process_images(image_processor, refs: Sequence[ImageRef], *, max_pixels: int = DEFAULT_MAX_PIXELS,
|
| 126 |
+
min_pixels: int = DEFAULT_MIN_PIXELS, fast: bool = True, device=None) -> ProcessedImages:
|
| 127 |
+
"""Preprocess each image (own detail cap each) and concatenate. fast=True uses fast_patchify (identical output)."""
|
| 128 |
+
import torch
|
| 129 |
+
|
| 130 |
+
if len(refs) > MAX_IMAGES_PER_REQUEST:
|
| 131 |
+
raise ValueError(f"at most {MAX_IMAGES_PER_REQUEST} images per request")
|
| 132 |
+
merge = int(getattr(image_processor, "merge_size", 2))
|
| 133 |
+
pv, grids, sizes, ids, ntoks = [], [], [], [], []
|
| 134 |
+
for r in refs:
|
| 135 |
+
im = load_image(r.image)
|
| 136 |
+
sizes.append(im.size)
|
| 137 |
+
cap = HIGH_DETAIL_MAX_PIXELS if r.detail == "high" else max_pixels
|
| 138 |
+
if fast:
|
| 139 |
+
x, g = fast_patchify(im, image_processor, min_pixels=min_pixels, max_pixels=cap, device=device)
|
| 140 |
+
pv.append(x)
|
| 141 |
+
else:
|
| 142 |
+
out = image_processor(images=[im], size={"shortest_edge": min_pixels, "longest_edge": cap}, return_tensors="pt")
|
| 143 |
+
pv.append(out["pixel_values"])
|
| 144 |
+
g = [int(v) for v in out["image_grid_thw"][0].tolist()]
|
| 145 |
+
grids.append(g)
|
| 146 |
+
ids.append(r.id)
|
| 147 |
+
ntoks.append(g[0] * g[1] * g[2] // (merge * merge))
|
| 148 |
+
if sum(ntoks) > MAX_VISUAL_TOKENS:
|
| 149 |
+
raise ValueError(f"images need {sum(ntoks)} visual tokens > {MAX_VISUAL_TOKENS}; lower detail or send fewer images")
|
| 150 |
+
return ProcessedImages(ids=ids, pixel_values=torch.cat(pv, 0), grid_thw=grids, sizes=sizes, n_tokens=ntoks, merge=merge)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def bbox_to_token_target(bbox: Optional[Sequence[float]], hm: int, wm: int) -> "torch.Tensor":
|
| 154 |
+
"""Soft target over the hm*wm tokens (+1 null slot at the end) for a normalised bbox [x0,y0,x1,y1] in 0..1.
|
| 155 |
+
|
| 156 |
+
Cells whose area overlaps the box get weight = overlap fraction of the cell (GUI-Actor style multi-patch
|
| 157 |
+
supervision); normalised to sum 1. bbox None -> all mass on the null slot ("not on screen").
|
| 158 |
+
"""
|
| 159 |
+
import torch
|
| 160 |
+
|
| 161 |
+
tgt = torch.zeros(hm * wm + 1)
|
| 162 |
+
if bbox is None:
|
| 163 |
+
tgt[-1] = 1.0
|
| 164 |
+
return tgt
|
| 165 |
+
x0, y0, x1, y1 = [float(v) for v in bbox]
|
| 166 |
+
x0, x1 = sorted((max(0.0, min(1.0, x0)), max(0.0, min(1.0, x1))))
|
| 167 |
+
y0, y1 = sorted((max(0.0, min(1.0, y0)), max(0.0, min(1.0, y1))))
|
| 168 |
+
# a degenerate (point) box marks the cell that contains it
|
| 169 |
+
if x1 - x0 < 1e-6:
|
| 170 |
+
x0, x1 = max(0.0, x0 - 0.5 / wm), min(1.0, x0 + 0.5 / wm)
|
| 171 |
+
if y1 - y0 < 1e-6:
|
| 172 |
+
y0, y1 = max(0.0, y0 - 0.5 / hm), min(1.0, y0 + 0.5 / hm)
|
| 173 |
+
for r in range(hm):
|
| 174 |
+
cy0, cy1 = r / hm, (r + 1) / hm
|
| 175 |
+
oy = max(0.0, min(cy1, y1) - max(cy0, y0))
|
| 176 |
+
if oy <= 0:
|
| 177 |
+
continue
|
| 178 |
+
for c in range(wm):
|
| 179 |
+
cx0, cx1 = c / wm, (c + 1) / wm
|
| 180 |
+
ox = max(0.0, min(cx1, x1) - max(cx0, x0))
|
| 181 |
+
if ox > 0:
|
| 182 |
+
tgt[r * wm + c] = ox * oy * hm * wm # fraction of the cell covered
|
| 183 |
+
if tgt[:-1].sum() <= 0:
|
| 184 |
+
tgt[-1] = 1.0
|
| 185 |
+
else:
|
| 186 |
+
tgt = tgt / tgt.sum()
|
| 187 |
+
return tgt
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def token_xy(index: int, hm: int, wm: int) -> Tuple[float, float]:
|
| 191 |
+
"""Centre of merged token `index` in normalised image coordinates."""
|
| 192 |
+
r, c = divmod(index, wm)
|
| 193 |
+
return (c + 0.5) / wm, (r + 0.5) / hm
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def heatmap_to_candidates(probs: Sequence[float], hm: int, wm: int, *, rel_threshold: float = 0.3, max_candidates: int = 5):
|
| 197 |
+
"""GUI-Actor decoding: threshold at rel_threshold * max, 8-connected components, mass-weighted centroids.
|
| 198 |
+
|
| 199 |
+
probs: hm*wm token probabilities (null slot already removed). Returns list of {x, y, p, bbox} sorted by p.
|
| 200 |
+
"""
|
| 201 |
+
n = hm * wm
|
| 202 |
+
p = [float(v) for v in probs[:n]]
|
| 203 |
+
if n == 0 or max(p) <= 0:
|
| 204 |
+
return []
|
| 205 |
+
thr = rel_threshold * max(p)
|
| 206 |
+
keep = {i for i, v in enumerate(p) if v >= thr}
|
| 207 |
+
seen, comps = set(), []
|
| 208 |
+
for start in sorted(keep, key=lambda i: -p[i]):
|
| 209 |
+
if start in seen:
|
| 210 |
+
continue
|
| 211 |
+
comp, stack = [], [start]
|
| 212 |
+
seen.add(start)
|
| 213 |
+
while stack:
|
| 214 |
+
i = stack.pop()
|
| 215 |
+
comp.append(i)
|
| 216 |
+
r, c = divmod(i, wm)
|
| 217 |
+
for dr in (-1, 0, 1):
|
| 218 |
+
for dc in (-1, 0, 1):
|
| 219 |
+
rr, cc = r + dr, c + dc
|
| 220 |
+
if 0 <= rr < hm and 0 <= cc < wm:
|
| 221 |
+
j = rr * wm + cc
|
| 222 |
+
if j in keep and j not in seen:
|
| 223 |
+
seen.add(j)
|
| 224 |
+
stack.append(j)
|
| 225 |
+
mass = sum(p[i] for i in comp)
|
| 226 |
+
xs = [token_xy(i, hm, wm) for i in comp]
|
| 227 |
+
x = sum(px * p[i] for (px, _), i in zip(xs, comp)) / mass
|
| 228 |
+
y = sum(py * p[i] for (_, py), i in zip(xs, comp)) / mass
|
| 229 |
+
rows = [i // wm for i in comp]
|
| 230 |
+
cols = [i % wm for i in comp]
|
| 231 |
+
bbox = [min(cols) / wm, min(rows) / hm, (max(cols) + 1) / wm, (max(rows) + 1) / hm]
|
| 232 |
+
comps.append({"x": round(x, 4), "y": round(y, 4), "p": round(mass, 4), "bbox": [round(v, 4) for v in bbox]})
|
| 233 |
+
comps.sort(key=lambda d: -d["p"])
|
| 234 |
+
return comps[:max_candidates]
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2b87567f3b76f4eacc34e55905d9a2e2e341833f6322478bd5263a7a96b66272
|
| 3 |
+
size 1009411440
|
modeling.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenThai-SystemOne decision model.
|
| 2 |
+
|
| 3 |
+
text tower (Qwen3.5-0.8B, LM head removed) -> hidden state at every <|ts_answer|> -> SlotHead (256 logits)
|
| 4 |
+
mask slots >= k -> softmax -> probabilities over the k options
|
| 5 |
+
|
| 6 |
+
-Vision variant: the tower is the multimodal Qwen3.5 model (ViT + merger + the same text tower). Images enter as
|
| 7 |
+
runs of <|image_pad|> tokens; `point` questions are read out by the PointHead, which attends from the <|ts_answer|>
|
| 8 |
+
hidden state over the LLM's final hidden states of that image's visual tokens (GUI-Actor recipe) plus a learned
|
| 9 |
+
"null" key for "not on the image". With no pixel_values the forward path is identical to the text-only model.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn as nn
|
| 19 |
+
import torch.nn.functional as F
|
| 20 |
+
from transformers import AutoModel, AutoModelForCausalLM, AutoTokenizer, PreTrainedModel
|
| 21 |
+
from transformers.utils import ModelOutput
|
| 22 |
+
|
| 23 |
+
from .configuration import OpenThaiSystemOneConfig
|
| 24 |
+
from .formatting import SPECIAL_TOKENS, TOK_ANSWER, TOK_POINT, QWEN_IMAGE_PAD, QWEN_VISION_END, QWEN_VISION_START, add_special_tokens
|
| 25 |
+
|
| 26 |
+
QTYPE_INDEX = {"choice": 0, "score": 1, "noul": 2, "point": 3}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class DecisionOutput(ModelOutput):
|
| 31 |
+
loss: Optional[torch.Tensor] = None
|
| 32 |
+
logits: Optional[torch.Tensor] = None # (B, Q, n_slots), masked with -inf
|
| 33 |
+
probs: Optional[torch.Tensor] = None # (B, Q, n_slots)
|
| 34 |
+
hidden_states: Optional[torch.Tensor] = None # (B, Q, H) at answer positions
|
| 35 |
+
point_logits: Optional[torch.Tensor] = None # (B, Q, L+1): per visual token of the referenced image + null; -inf elsewhere
|
| 36 |
+
point_probs: Optional[torch.Tensor] = None
|
| 37 |
+
slot_loss: Optional[torch.Tensor] = None
|
| 38 |
+
point_loss: Optional[torch.Tensor] = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PointHead(nn.Module):
|
| 42 |
+
"""Attention readout over one image's visual tokens (final-layer LLM states).
|
| 43 |
+
|
| 44 |
+
q = MLP_T(h_answer), k_i = MLP_V(SA(v_i)), logits_i = q.k_i / sqrt(d) / T ; extra null key = "not on the image".
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self, hidden: int, dim: int = 256, n_sa_layers: int = 1, n_heads: int = 8):
|
| 48 |
+
super().__init__()
|
| 49 |
+
self.sa = nn.ModuleList(
|
| 50 |
+
[nn.TransformerEncoderLayer(hidden, n_heads, dim_feedforward=2 * hidden, dropout=0.0, batch_first=True, norm_first=True) for _ in range(n_sa_layers)]
|
| 51 |
+
)
|
| 52 |
+
self.q_proj = nn.Sequential(nn.Linear(hidden, dim), nn.GELU(), nn.Linear(dim, dim))
|
| 53 |
+
self.k_proj = nn.Sequential(nn.Linear(hidden, dim), nn.GELU(), nn.Linear(dim, dim))
|
| 54 |
+
self.null_key = nn.Parameter(torch.zeros(dim))
|
| 55 |
+
self.log_temperature = nn.Parameter(torch.zeros(()))
|
| 56 |
+
self.dim = dim
|
| 57 |
+
|
| 58 |
+
def forward(self, h_answer: torch.Tensor, visual: torch.Tensor, visual_mask: torch.Tensor) -> torch.Tensor:
|
| 59 |
+
"""h_answer (N, H); visual (N, L, H) right-padded; visual_mask (N, L) True = real token. Returns (N, L+1) logits."""
|
| 60 |
+
x = visual
|
| 61 |
+
for layer in self.sa:
|
| 62 |
+
x = layer(x, src_key_padding_mask=~visual_mask)
|
| 63 |
+
k = self.k_proj(x) # (N, L, d)
|
| 64 |
+
q = self.q_proj(h_answer) # (N, d)
|
| 65 |
+
logits = torch.einsum("nd,nld->nl", q, k) / math.sqrt(self.dim)
|
| 66 |
+
null = (q @ self.null_key)[:, None] / math.sqrt(self.dim)
|
| 67 |
+
logits = torch.cat([logits, null], dim=1) / self.log_temperature.exp()
|
| 68 |
+
pad = torch.cat([~visual_mask, torch.zeros_like(visual_mask[:, :1])], dim=1)
|
| 69 |
+
return logits.masked_fill(pad, float("-inf"))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class OpenThaiSystemOneForDecision(PreTrainedModel):
|
| 73 |
+
config_class = OpenThaiSystemOneConfig
|
| 74 |
+
base_model_prefix = "model"
|
| 75 |
+
supports_gradient_checkpointing = True
|
| 76 |
+
_supports_flash_attn = True
|
| 77 |
+
_supports_sdpa = True
|
| 78 |
+
|
| 79 |
+
def __init__(self, config: OpenThaiSystemOneConfig):
|
| 80 |
+
super().__init__(config)
|
| 81 |
+
# Parameter layout is the SAME as the text-only line for everything they share: text tower `model.*`,
|
| 82 |
+
# `slot_head.*`, 3 `log_temperature`s. The vision parts sit beside it (`visual.*`, `point_head.*`), so a
|
| 83 |
+
# text-only v0.x client loading a -Vision checkpoint finds every weight it knows and ignores the rest.
|
| 84 |
+
self.model = AutoModel.from_config(config.text_config)
|
| 85 |
+
if config.is_vision:
|
| 86 |
+
self.visual = AutoModel.from_config(config.vision_config)
|
| 87 |
+
enable_vision_helper_caches()
|
| 88 |
+
ph = dict(config.point_head or {})
|
| 89 |
+
self.point_head = PointHead(config.hidden_size, ph.get("dim", 256), ph.get("n_sa_layers", 1), ph.get("n_heads", 8))
|
| 90 |
+
else:
|
| 91 |
+
self.visual = None
|
| 92 |
+
self.point_head = None
|
| 93 |
+
self.slot_head = nn.Linear(config.hidden_size, config.n_slots, bias=config.head_bias)
|
| 94 |
+
# log-temperatures per question type (choice/score/noul[/point]); learned in the calibration stage
|
| 95 |
+
self.log_temperature = nn.Parameter(torch.zeros(config.n_temperatures))
|
| 96 |
+
self.post_init()
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def language_model(self):
|
| 100 |
+
return self.model
|
| 101 |
+
|
| 102 |
+
def encode(self, input_ids, attention_mask=None, pixel_values=None, image_grid_thw=None, position_ids=None, **kwargs):
|
| 103 |
+
"""Final hidden states. With images: ViT -> merger -> splice into the <|image_pad|> positions, 3-D (t,h,w) position
|
| 104 |
+
ids (computed here when not given), then the text tower. Without images: the plain text-tower forward."""
|
| 105 |
+
if pixel_values is None:
|
| 106 |
+
return self.model(input_ids=input_ids, attention_mask=attention_mask, position_ids=position_ids, **kwargs).last_hidden_state
|
| 107 |
+
if self.visual is None:
|
| 108 |
+
raise ValueError("this checkpoint is text-only; images need OpenThai-SystemOne-Vision")
|
| 109 |
+
_VISION_TARGET_DEVICE[0] = pixel_values.device
|
| 110 |
+
embeds = self.model.get_input_embeddings()(input_ids)
|
| 111 |
+
img = self.visual(pixel_values.to(self.visual.dtype), grid_thw=image_grid_thw).pooler_output
|
| 112 |
+
mask = (input_ids == self.config.image_token_id).unsqueeze(-1)
|
| 113 |
+
if int(mask.sum()) != img.shape[0]:
|
| 114 |
+
raise ValueError(f"image tokens ({int(mask.sum())}) != image features ({img.shape[0]})")
|
| 115 |
+
embeds = embeds.masked_scatter(mask.to(embeds.device), img.to(embeds.device, embeds.dtype))
|
| 116 |
+
if position_ids is None:
|
| 117 |
+
position_ids = mrope_positions_from_ids(input_ids, attention_mask, image_grid_thw, self.config.image_token_id,
|
| 118 |
+
int(getattr(self.config.vision_config, "spatial_merge_size", 2)))
|
| 119 |
+
return self.model(inputs_embeds=embeds, attention_mask=attention_mask, position_ids=position_ids.to(embeds.device), **kwargs).last_hidden_state
|
| 120 |
+
|
| 121 |
+
def get_input_embeddings(self):
|
| 122 |
+
return self.language_model.get_input_embeddings()
|
| 123 |
+
|
| 124 |
+
def set_input_embeddings(self, value):
|
| 125 |
+
self.language_model.set_input_embeddings(value)
|
| 126 |
+
|
| 127 |
+
# ------------------------------------------------------------------ construction
|
| 128 |
+
@classmethod
|
| 129 |
+
def from_causal_lm(
|
| 130 |
+
cls,
|
| 131 |
+
path: str,
|
| 132 |
+
*,
|
| 133 |
+
tokenizer=None,
|
| 134 |
+
n_slots: int = 256,
|
| 135 |
+
torch_dtype=torch.bfloat16,
|
| 136 |
+
**kwargs,
|
| 137 |
+
):
|
| 138 |
+
"""Build a text-only decision model from a (text-only) causal-LM checkpoint: drop lm_head, add tokens + head."""
|
| 139 |
+
tok = tokenizer or AutoTokenizer.from_pretrained(path)
|
| 140 |
+
added = add_special_tokens(tok)
|
| 141 |
+
lm = AutoModelForCausalLM.from_pretrained(path, dtype=torch_dtype, **kwargs)
|
| 142 |
+
base = lm.model if hasattr(lm, "model") else lm.base_model
|
| 143 |
+
text_cfg = base.config
|
| 144 |
+
if added:
|
| 145 |
+
lm.resize_token_embeddings(len(tok), mean_resizing=False)
|
| 146 |
+
text_cfg.vocab_size = lm.get_input_embeddings().weight.shape[0]
|
| 147 |
+
_init_new_token_embeddings(lm.get_input_embeddings().weight, tok, added)
|
| 148 |
+
cfg = OpenThaiSystemOneConfig(
|
| 149 |
+
text_config=text_cfg,
|
| 150 |
+
n_slots=n_slots,
|
| 151 |
+
answer_token_id=tok.convert_tokens_to_ids(TOK_ANSWER),
|
| 152 |
+
pad_token_id=tok.pad_token_id,
|
| 153 |
+
)
|
| 154 |
+
cfg.text_config.tie_word_embeddings = False # there is no LM head any more
|
| 155 |
+
model = cls(cfg).to(torch_dtype)
|
| 156 |
+
missing, unexpected = model.model.load_state_dict(base.state_dict(), strict=False)
|
| 157 |
+
assert not unexpected, unexpected
|
| 158 |
+
_init_slot_head(model.slot_head)
|
| 159 |
+
model.model.config = cfg.text_config
|
| 160 |
+
return model, tok
|
| 161 |
+
|
| 162 |
+
@classmethod
|
| 163 |
+
def from_text_decision_model(
|
| 164 |
+
cls,
|
| 165 |
+
text_path: str,
|
| 166 |
+
vision_source: str,
|
| 167 |
+
*,
|
| 168 |
+
tokenizer=None,
|
| 169 |
+
point_head: Optional[dict] = None,
|
| 170 |
+
torch_dtype=torch.bfloat16,
|
| 171 |
+
):
|
| 172 |
+
"""Build the -Vision model: text tower + slot head from a text-only decision checkpoint (e.g. v0.3), the ViT +
|
| 173 |
+
merger from a Qwen3.5 multimodal checkpoint (base/Qwen3.5-0.8B-Base), a fresh PointHead, and the two extra
|
| 174 |
+
control tokens. Text-only forward of the result equals the source model."""
|
| 175 |
+
import glob
|
| 176 |
+
import json
|
| 177 |
+
import os
|
| 178 |
+
|
| 179 |
+
from safetensors import safe_open
|
| 180 |
+
from transformers import AutoConfig
|
| 181 |
+
|
| 182 |
+
text_model = cls.from_pretrained(text_path, dtype=torch_dtype)
|
| 183 |
+
assert not text_model.config.is_vision, "source must be the text-only model"
|
| 184 |
+
tok = tokenizer or AutoTokenizer.from_pretrained(text_path)
|
| 185 |
+
added = add_special_tokens(tok, vision=True)
|
| 186 |
+
src_cfg = AutoConfig.from_pretrained(vision_source)
|
| 187 |
+
vcfg = src_cfg.vision_config
|
| 188 |
+
vcfg.out_hidden_size = text_model.config.hidden_size
|
| 189 |
+
cfg = OpenThaiSystemOneConfig(
|
| 190 |
+
text_config=text_model.config.text_config,
|
| 191 |
+
n_slots=text_model.config.n_slots,
|
| 192 |
+
abstain_slot=text_model.config.abstain_slot,
|
| 193 |
+
answer_token_id=text_model.config.answer_token_id,
|
| 194 |
+
head_bias=text_model.config.head_bias,
|
| 195 |
+
n_temperatures=text_model.config.n_temperatures,
|
| 196 |
+
vision_config=vcfg,
|
| 197 |
+
image_token_id=tok.convert_tokens_to_ids(QWEN_IMAGE_PAD),
|
| 198 |
+
vision_start_token_id=tok.convert_tokens_to_ids(QWEN_VISION_START),
|
| 199 |
+
vision_end_token_id=tok.convert_tokens_to_ids(QWEN_VISION_END),
|
| 200 |
+
point_token_id=tok.convert_tokens_to_ids(TOK_POINT),
|
| 201 |
+
point_head=point_head or {"dim": 256, "n_sa_layers": 1, "n_heads": 8},
|
| 202 |
+
pad_token_id=tok.pad_token_id,
|
| 203 |
+
)
|
| 204 |
+
cfg.text_config.vocab_size = len(tok)
|
| 205 |
+
model = cls(cfg).to(torch_dtype)
|
| 206 |
+
# text tower + heads
|
| 207 |
+
old_vocab = text_model.get_input_embeddings().weight.shape[0]
|
| 208 |
+
if len(tok) > old_vocab:
|
| 209 |
+
text_model.model.resize_token_embeddings(len(tok), mean_resizing=False)
|
| 210 |
+
_init_new_token_embeddings(text_model.get_input_embeddings().weight, tok, len(tok) - old_vocab)
|
| 211 |
+
missing, unexpected = model.model.load_state_dict(text_model.model.state_dict(), strict=False)
|
| 212 |
+
assert not unexpected and not missing, (missing, unexpected)
|
| 213 |
+
model.slot_head.load_state_dict(text_model.slot_head.state_dict())
|
| 214 |
+
with torch.no_grad():
|
| 215 |
+
n = min(text_model.log_temperature.numel(), model.log_temperature.numel())
|
| 216 |
+
model.log_temperature[:n] = text_model.log_temperature[:n]
|
| 217 |
+
# vision tower from the multimodal checkpoint
|
| 218 |
+
files = sorted(glob.glob(os.path.join(vision_source, "*.safetensors")))
|
| 219 |
+
sd = {}
|
| 220 |
+
for f in files:
|
| 221 |
+
with safe_open(f, "pt") as fh:
|
| 222 |
+
for k in fh.keys():
|
| 223 |
+
for prefix in ("model.visual.", "visual."):
|
| 224 |
+
if k.startswith(prefix):
|
| 225 |
+
sd[k[len(prefix):]] = fh.get_tensor(k)
|
| 226 |
+
break
|
| 227 |
+
missing, unexpected = model.visual.load_state_dict({k: v.to(torch_dtype) for k, v in sd.items()}, strict=False)
|
| 228 |
+
assert not unexpected and not missing, (missing, unexpected)
|
| 229 |
+
model.model.config = cfg.text_config
|
| 230 |
+
return model, tok
|
| 231 |
+
|
| 232 |
+
# ------------------------------------------------------------------ forward
|
| 233 |
+
def gather_answer_states(self, hidden: torch.Tensor, answer_positions: torch.Tensor) -> torch.Tensor:
|
| 234 |
+
idx = answer_positions.clamp(min=0).unsqueeze(-1).expand(-1, -1, hidden.shape[-1])
|
| 235 |
+
return torch.gather(hidden, 1, idx) # (B, Q, H)
|
| 236 |
+
|
| 237 |
+
def slot_logits(
|
| 238 |
+
self,
|
| 239 |
+
answer_hidden: torch.Tensor,
|
| 240 |
+
option_counts: torch.Tensor,
|
| 241 |
+
*,
|
| 242 |
+
include_abstain: bool = True,
|
| 243 |
+
qtypes: Optional[torch.Tensor] = None,
|
| 244 |
+
apply_temperature: bool = True,
|
| 245 |
+
) -> torch.Tensor:
|
| 246 |
+
logits = self.slot_head(answer_hidden.to(self.slot_head.weight.dtype)).float()
|
| 247 |
+
if apply_temperature:
|
| 248 |
+
if qtypes is None:
|
| 249 |
+
t = self.log_temperature[0].exp()
|
| 250 |
+
else:
|
| 251 |
+
t = self.log_temperature.exp()[qtypes.clamp(min=0, max=self.log_temperature.numel() - 1)] # (B, Q)
|
| 252 |
+
t = t.unsqueeze(-1)
|
| 253 |
+
logits = logits / t
|
| 254 |
+
ar = torch.arange(logits.shape[-1], device=logits.device)
|
| 255 |
+
valid = ar[None, None, :] < option_counts.unsqueeze(-1)
|
| 256 |
+
if include_abstain:
|
| 257 |
+
valid = valid.clone()
|
| 258 |
+
valid[..., self.config.abstain_slot] = True
|
| 259 |
+
# questions that are padding or point questions (option_counts == 0) keep slot 0 valid to avoid NaNs
|
| 260 |
+
valid[..., 0] |= option_counts.unsqueeze(-1).squeeze(-1) == 0
|
| 261 |
+
return logits.masked_fill(~valid, float("-inf"))
|
| 262 |
+
|
| 263 |
+
def point_logits(
|
| 264 |
+
self,
|
| 265 |
+
hidden: torch.Tensor,
|
| 266 |
+
answer_hidden: torch.Tensor,
|
| 267 |
+
image_spans: torch.Tensor,
|
| 268 |
+
point_image_index: torch.Tensor,
|
| 269 |
+
*,
|
| 270 |
+
max_tokens: Optional[int] = None,
|
| 271 |
+
) -> Optional[torch.Tensor]:
|
| 272 |
+
"""(B, Q, L+1) logits for point questions (-inf rows elsewhere)."""
|
| 273 |
+
B, Q = point_image_index.shape
|
| 274 |
+
sel = (point_image_index >= 0).nonzero(as_tuple=False)
|
| 275 |
+
L = max_tokens or int((image_spans[..., 1] - image_spans[..., 0]).clamp(min=0).max().item())
|
| 276 |
+
out = torch.full((B, Q, L + 1), float("-inf"), device=hidden.device)
|
| 277 |
+
if sel.numel() == 0:
|
| 278 |
+
return out
|
| 279 |
+
n = sel.shape[0]
|
| 280 |
+
vis = hidden.new_zeros((n, L, hidden.shape[-1]))
|
| 281 |
+
mask = torch.zeros((n, L), dtype=torch.bool, device=hidden.device)
|
| 282 |
+
for r, (b, q) in enumerate(sel.tolist()):
|
| 283 |
+
s0, e0 = image_spans[b, point_image_index[b, q]].tolist()
|
| 284 |
+
vis[r, : e0 - s0] = hidden[b, s0:e0]
|
| 285 |
+
mask[r, : e0 - s0] = True
|
| 286 |
+
h = answer_hidden[sel[:, 0], sel[:, 1]]
|
| 287 |
+
pl = self.point_head(h.to(vis.dtype), vis, mask).float()
|
| 288 |
+
out[sel[:, 0], sel[:, 1]] = pl
|
| 289 |
+
return out
|
| 290 |
+
|
| 291 |
+
def forward(
|
| 292 |
+
self,
|
| 293 |
+
input_ids: torch.Tensor,
|
| 294 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 295 |
+
answer_positions: Optional[torch.Tensor] = None,
|
| 296 |
+
option_counts: Optional[torch.Tensor] = None,
|
| 297 |
+
labels: Optional[torch.Tensor] = None,
|
| 298 |
+
soft_labels: Optional[torch.Tensor] = None,
|
| 299 |
+
qtypes: Optional[torch.Tensor] = None,
|
| 300 |
+
include_abstain: bool = True,
|
| 301 |
+
label_smoothing: float = 0.0,
|
| 302 |
+
brier_weight: float = 0.0,
|
| 303 |
+
apply_temperature: bool = True,
|
| 304 |
+
pixel_values: Optional[torch.Tensor] = None,
|
| 305 |
+
image_grid_thw: Optional[torch.Tensor] = None,
|
| 306 |
+
mm_token_type_ids: Optional[torch.Tensor] = None,
|
| 307 |
+
image_spans: Optional[torch.Tensor] = None,
|
| 308 |
+
point_image_index: Optional[torch.Tensor] = None,
|
| 309 |
+
point_targets: Optional[torch.Tensor] = None,
|
| 310 |
+
point_has_target: Optional[torch.Tensor] = None,
|
| 311 |
+
point_loss_weight: float = 1.0,
|
| 312 |
+
position_ids: Optional[torch.Tensor] = None,
|
| 313 |
+
**kwargs,
|
| 314 |
+
) -> DecisionOutput:
|
| 315 |
+
# position_ids (3, B, T) from formatting.mrope_position_ids avoid recomputing them; image_grid_thw may stay on the CPU
|
| 316 |
+
hidden = self.encode(input_ids, attention_mask=attention_mask, pixel_values=pixel_values, image_grid_thw=image_grid_thw,
|
| 317 |
+
position_ids=position_ids if pixel_values is not None else None, **kwargs)
|
| 318 |
+
if answer_positions is None:
|
| 319 |
+
answer_positions = (input_ids == self.config.answer_token_id).nonzero()[:, 1].unsqueeze(0)
|
| 320 |
+
if option_counts is None:
|
| 321 |
+
raise ValueError("option_counts required")
|
| 322 |
+
h = self.gather_answer_states(hidden, answer_positions)
|
| 323 |
+
logits = self.slot_logits(h, option_counts, include_abstain=include_abstain, qtypes=qtypes, apply_temperature=apply_temperature)
|
| 324 |
+
probs = logits.softmax(-1)
|
| 325 |
+
|
| 326 |
+
p_logits = p_probs = None
|
| 327 |
+
if self.point_head is not None and point_image_index is not None and (point_image_index >= 0).any():
|
| 328 |
+
p_logits = self.point_logits(hidden, h, image_spans, point_image_index,
|
| 329 |
+
max_tokens=(point_targets.shape[-1] - 1) if point_targets is not None else None)
|
| 330 |
+
p_probs = p_logits.softmax(-1)
|
| 331 |
+
p_probs = p_probs.masked_fill(torch.isinf(p_logits).all(-1, keepdim=True), 0.0)
|
| 332 |
+
|
| 333 |
+
loss = slot_loss = point_loss = None
|
| 334 |
+
if labels is not None or soft_labels is not None:
|
| 335 |
+
logp = logits.log_softmax(-1)
|
| 336 |
+
if soft_labels is not None:
|
| 337 |
+
valid = (option_counts > 0)
|
| 338 |
+
tgt = soft_labels.float()
|
| 339 |
+
nll = -(tgt * logp.masked_fill(torch.isinf(logp), 0.0)).sum(-1)
|
| 340 |
+
slot_loss = (nll * valid).sum() / valid.sum().clamp(min=1)
|
| 341 |
+
else:
|
| 342 |
+
flat_logp = logp.reshape(-1, logp.shape[-1])
|
| 343 |
+
flat_lab = labels.reshape(-1)
|
| 344 |
+
keep = flat_lab != -100
|
| 345 |
+
if keep.any():
|
| 346 |
+
lp = flat_logp[keep]
|
| 347 |
+
lb = flat_lab[keep]
|
| 348 |
+
nll = -lp.gather(1, lb[:, None]).squeeze(1)
|
| 349 |
+
if label_smoothing > 0:
|
| 350 |
+
n_valid = torch.isfinite(lp).sum(-1).clamp(min=1).float()
|
| 351 |
+
smooth = -(lp.masked_fill(torch.isinf(lp), 0.0)).sum(-1) / n_valid
|
| 352 |
+
nll = (1 - label_smoothing) * nll + label_smoothing * smooth
|
| 353 |
+
slot_loss = nll.mean()
|
| 354 |
+
if brier_weight > 0:
|
| 355 |
+
p = lp.exp()
|
| 356 |
+
onehot = F.one_hot(lb, p.shape[-1]).float()
|
| 357 |
+
slot_loss = slot_loss + brier_weight * ((p - onehot) ** 2).sum(-1).mean()
|
| 358 |
+
else:
|
| 359 |
+
slot_loss = logits.sum() * 0.0
|
| 360 |
+
loss = slot_loss
|
| 361 |
+
if p_logits is not None and point_targets is not None and point_has_target is not None and point_has_target.any():
|
| 362 |
+
lp = p_logits.log_softmax(-1).masked_fill(torch.isinf(p_logits), 0.0)
|
| 363 |
+
tgt = point_targets.float()
|
| 364 |
+
nll = -(tgt * lp).sum(-1) # cross-entropy against the soft mask (= KL up to the target entropy)
|
| 365 |
+
point_loss = (nll * point_has_target).sum() / point_has_target.sum().clamp(min=1)
|
| 366 |
+
loss = point_loss * point_loss_weight if loss is None else loss + point_loss_weight * point_loss
|
| 367 |
+
return DecisionOutput(loss=loss, logits=logits, probs=probs, hidden_states=h, point_logits=p_logits, point_probs=p_probs,
|
| 368 |
+
slot_loss=slot_loss, point_loss=point_loss)
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
_VISION_CACHE_ON = False
|
| 372 |
+
_VISION_TARGET_DEVICE = [None] # set by the forward: cached ViT helper outputs are moved here (lets image_grid_thw stay on the CPU)
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def enable_vision_helper_caches(maxsize: int = 512):
|
| 376 |
+
"""Memoise the Qwen3.5 ViT's per-call helpers by image grid.
|
| 377 |
+
|
| 378 |
+
`get_vision_interpolation_indices_and_weights`, `get_vision_position_ids` and `get_vision_attention_seqlens` rebuild
|
| 379 |
+
the same index tensors on every forward (tens of ms of small ops for a 320x240 frame). Their outputs depend only on
|
| 380 |
+
the grid (t, h, w) and the device, so they are cached; a Doom loop or a fixed-size screenshot stream hits the cache
|
| 381 |
+
every step. Idempotent; called automatically when a vision model is built.
|
| 382 |
+
"""
|
| 383 |
+
global _VISION_CACHE_ON
|
| 384 |
+
if _VISION_CACHE_ON:
|
| 385 |
+
return
|
| 386 |
+
try:
|
| 387 |
+
from transformers.models.qwen3_5 import modeling_qwen3_5 as mq
|
| 388 |
+
except Exception: # pragma: no cover
|
| 389 |
+
return
|
| 390 |
+
|
| 391 |
+
def cached(fn, key_extra=lambda *a, **k: ()):
|
| 392 |
+
cache = {}
|
| 393 |
+
|
| 394 |
+
def wrapper(grid_thw, *args, **kwargs):
|
| 395 |
+
kw = {k: v for k, v in kwargs.items() if k != "kwargs"}
|
| 396 |
+
target = _VISION_TARGET_DEVICE[0] or grid_thw.device
|
| 397 |
+
key = (tuple(map(tuple, grid_thw.tolist())), str(target), tuple(sorted((k, str(v)) for k, v in kw.items())), tuple(str(a) for a in args))
|
| 398 |
+
hit = cache.get(key)
|
| 399 |
+
if hit is None:
|
| 400 |
+
hit = fn(grid_thw, *args, **kwargs)
|
| 401 |
+
|
| 402 |
+
def mv(x):
|
| 403 |
+
return x.to(target) if torch.is_tensor(x) else x
|
| 404 |
+
|
| 405 |
+
hit = tuple(mv(x) for x in hit) if isinstance(hit, tuple) else mv(hit)
|
| 406 |
+
if len(cache) >= maxsize:
|
| 407 |
+
cache.clear()
|
| 408 |
+
cache[key] = hit
|
| 409 |
+
return hit
|
| 410 |
+
|
| 411 |
+
wrapper.__wrapped__ = fn
|
| 412 |
+
return wrapper
|
| 413 |
+
|
| 414 |
+
for name in ("get_vision_interpolation_indices_and_weights", "get_vision_position_ids", "get_vision_attention_seqlens"):
|
| 415 |
+
fn = getattr(mq, name, None)
|
| 416 |
+
if fn is not None and not hasattr(fn, "__wrapped__"):
|
| 417 |
+
setattr(mq, name, cached(fn))
|
| 418 |
+
_VISION_CACHE_ON = True
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def mrope_positions_from_ids(input_ids, attention_mask, image_grid_thw, image_token_id: int, merge: int):
|
| 422 |
+
"""3-D position ids from token ids alone (same result as formatting.mrope_position_ids / Qwen's get_rope_index)."""
|
| 423 |
+
B, T = input_ids.shape
|
| 424 |
+
grids = [[int(v) for v in g] for g in image_grid_thw.tolist()]
|
| 425 |
+
ids = input_ids.tolist()
|
| 426 |
+
am = attention_mask.tolist() if attention_mask is not None else [[1] * T for _ in range(B)]
|
| 427 |
+
pos = torch.zeros((3, B, T), dtype=torch.long)
|
| 428 |
+
gi = 0
|
| 429 |
+
for b in range(B):
|
| 430 |
+
cur, i = 0, 0
|
| 431 |
+
n = sum(am[b])
|
| 432 |
+
while i < n:
|
| 433 |
+
if ids[b][i] == image_token_id:
|
| 434 |
+
t, h, w = grids[gi]; gi += 1
|
| 435 |
+
hm, wm = h // merge, w // merge
|
| 436 |
+
L = t * hm * wm
|
| 437 |
+
tt = torch.arange(t).view(t, 1, 1).expand(t, hm, wm).reshape(-1)
|
| 438 |
+
hh = torch.arange(hm).view(1, hm, 1).expand(t, hm, wm).reshape(-1)
|
| 439 |
+
ww = torch.arange(wm).view(1, 1, wm).expand(t, hm, wm).reshape(-1)
|
| 440 |
+
pos[0, b, i:i + L] = tt + cur; pos[1, b, i:i + L] = hh + cur; pos[2, b, i:i + L] = ww + cur
|
| 441 |
+
cur += max(hm, wm); i += L
|
| 442 |
+
else:
|
| 443 |
+
j = i
|
| 444 |
+
while j < n and ids[b][j] != image_token_id:
|
| 445 |
+
j += 1
|
| 446 |
+
pos[:, b, i:j] = torch.arange(j - i) + cur
|
| 447 |
+
cur += j - i; i = j
|
| 448 |
+
return pos
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def convert_legacy_vision_state_dict(sd: dict) -> dict:
|
| 452 |
+
"""Checkpoints trained before 2026-09-23 stored the vision model as a Qwen3_5Model (`model.language_model.*`,
|
| 453 |
+
`model.visual.*`, 4 temperatures). Map them onto the shared layout (`model.*`, `visual.*`, 3 temperatures)."""
|
| 454 |
+
out = {}
|
| 455 |
+
for k, v in sd.items():
|
| 456 |
+
if k.startswith("model.language_model."):
|
| 457 |
+
out["model." + k[len("model.language_model."):]] = v
|
| 458 |
+
elif k.startswith("model.visual."):
|
| 459 |
+
out["visual." + k[len("model.visual."):]] = v
|
| 460 |
+
elif k == "log_temperature" and v.numel() == 4:
|
| 461 |
+
out[k] = v[:3].clone()
|
| 462 |
+
else:
|
| 463 |
+
out[k] = v
|
| 464 |
+
return out
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def use_reference_kernels():
|
| 468 |
+
"""Force the pure-PyTorch Gated-DeltaNet / causal-conv paths.
|
| 469 |
+
|
| 470 |
+
transformers routes `chunk_gated_delta_rule` & co. to the Triton kernels (flash-linear-attention, causal-conv1d)
|
| 471 |
+
whenever those packages are importable, without checking the tensor device, which crashes on CPU/MPS.
|
| 472 |
+
Call this before running on a non-CUDA device.
|
| 473 |
+
"""
|
| 474 |
+
try:
|
| 475 |
+
from transformers.models.qwen3_5 import modeling_qwen3_5 as m
|
| 476 |
+
except Exception: # pragma: no cover
|
| 477 |
+
return
|
| 478 |
+
for name in ("torch_chunk_gated_delta_rule", "torch_recurrent_gated_delta_rule", "chunk_gated_delta_rule",
|
| 479 |
+
"fused_recurrent_gated_delta_rule", "causal_conv1d_fn", "causal_conv1d_update"):
|
| 480 |
+
fn = getattr(m, name, None)
|
| 481 |
+
if fn is not None and hasattr(fn, "__wrapped__"):
|
| 482 |
+
setattr(m, name, fn.__wrapped__)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def _init_slot_head(head: nn.Linear):
|
| 486 |
+
nn.init.normal_(head.weight, std=0.02)
|
| 487 |
+
if head.bias is not None:
|
| 488 |
+
nn.init.zeros_(head.bias)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
@torch.no_grad()
|
| 492 |
+
def _init_new_token_embeddings(weight: torch.Tensor, tok, n_added: int):
|
| 493 |
+
"""New control tokens start near the mean of digit-token embeddings + small noise."""
|
| 494 |
+
digit_ids = [tok.convert_tokens_to_ids(d) for d in "0123456789"]
|
| 495 |
+
digit_ids = [i for i in digit_ids if i is not None and i != tok.unk_token_id]
|
| 496 |
+
mean = weight[digit_ids].float().mean(0) if digit_ids else weight[: weight.shape[0] - n_added].float().mean(0)
|
| 497 |
+
std = weight[: weight.shape[0] - n_added].float().std()
|
| 498 |
+
new = mean[None, :] + torch.randn(n_added, weight.shape[1]) * std * 0.1
|
| 499 |
+
weight[-n_added:] = new.to(weight.dtype)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def confidence_from_probs(p: torch.Tensor, k: int) -> float:
|
| 503 |
+
"""1 - normalised entropy over the k valid options."""
|
| 504 |
+
if k <= 1:
|
| 505 |
+
return 1.0
|
| 506 |
+
p = p[:k].clamp(min=1e-12)
|
| 507 |
+
p = p / p.sum()
|
| 508 |
+
h = -(p * p.log()).sum().item()
|
| 509 |
+
return float(max(0.0, min(1.0, 1.0 - h / math.log(k))))
|
tokenizer.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d3400f1532d54f98d82b24c503e61908aa66f84e22e566e59fc80494add3a5c3
|
| 3 |
+
size 20039242
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_prefix_space": false,
|
| 3 |
+
"audio_bos_token": "<|audio_start|>",
|
| 4 |
+
"audio_eos_token": "<|audio_end|>",
|
| 5 |
+
"audio_token": "<|audio_pad|>",
|
| 6 |
+
"backend": "tokenizers",
|
| 7 |
+
"bos_token": null,
|
| 8 |
+
"clean_up_tokenization_spaces": false,
|
| 9 |
+
"eos_token": "<|endoftext|>",
|
| 10 |
+
"errors": "replace",
|
| 11 |
+
"image_token": "<|image_pad|>",
|
| 12 |
+
"is_local": true,
|
| 13 |
+
"local_files_only": false,
|
| 14 |
+
"model_max_length": 262144,
|
| 15 |
+
"model_specific_special_tokens": {
|
| 16 |
+
"audio_bos_token": "<|audio_start|>",
|
| 17 |
+
"audio_eos_token": "<|audio_end|>",
|
| 18 |
+
"audio_token": "<|audio_pad|>",
|
| 19 |
+
"image_token": "<|image_pad|>",
|
| 20 |
+
"video_token": "<|video_pad|>",
|
| 21 |
+
"vision_bos_token": "<|vision_start|>",
|
| 22 |
+
"vision_eos_token": "<|vision_end|>"
|
| 23 |
+
},
|
| 24 |
+
"pad_token": "<|endoftext|>",
|
| 25 |
+
"pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
|
| 26 |
+
"split_special_tokens": false,
|
| 27 |
+
"tokenizer_class": "Qwen2Tokenizer",
|
| 28 |
+
"unk_token": null,
|
| 29 |
+
"video_token": "<|video_pad|>",
|
| 30 |
+
"vision_bos_token": "<|vision_start|>",
|
| 31 |
+
"vision_eos_token": "<|vision_end|>"
|
| 32 |
+
}
|
types.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Request / response types.
|
| 2 |
+
|
| 3 |
+
Field names deliberately mirror the TypeSafe `POST /v1/systemone` contract so that
|
| 4 |
+
code written against the TypeSafe SDK can be pointed at OpenThai-SystemOne unchanged:
|
| 5 |
+
|
| 6 |
+
state : str | dict | list -- the thing to judge
|
| 7 |
+
questions : {id: Choice|Score|Noul} -- typed questions
|
| 8 |
+
answers : {id: ChoiceAnswer|ScoreAnswer|NoulAnswer}
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Any, Dict, List, Literal, Optional, Union
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 15 |
+
|
| 16 |
+
MAX_OPTIONS = 255 # single-stage cardinality limit (slots 0..254); slot 255 = abstain
|
| 17 |
+
MIN_SCORE_LEVELS = 2
|
| 18 |
+
MAX_SCORE_LEVELS = 10
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Noul(BaseModel):
|
| 22 |
+
"""A yes/no question. Returns p(yes)."""
|
| 23 |
+
|
| 24 |
+
type: Literal["noul"] = "noul"
|
| 25 |
+
instructions: str
|
| 26 |
+
criteria: Optional[Dict[str, Optional[str]]] = None # {"true": "...", "false": "..."}
|
| 27 |
+
|
| 28 |
+
@field_validator("criteria")
|
| 29 |
+
@classmethod
|
| 30 |
+
def _check_criteria(cls, v):
|
| 31 |
+
if v is None:
|
| 32 |
+
return v
|
| 33 |
+
extra = set(v) - {"true", "false"}
|
| 34 |
+
if extra:
|
| 35 |
+
raise ValueError(f"noul criteria keys must be 'true'/'false', got {sorted(extra)}")
|
| 36 |
+
return v
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Choice(BaseModel):
|
| 40 |
+
"""Pick one option. `criteria` maps option name -> description (or null)."""
|
| 41 |
+
|
| 42 |
+
type: Literal["choice"] = "choice"
|
| 43 |
+
instructions: str
|
| 44 |
+
criteria: Dict[str, Optional[str]]
|
| 45 |
+
|
| 46 |
+
@field_validator("criteria")
|
| 47 |
+
@classmethod
|
| 48 |
+
def _check_criteria(cls, v):
|
| 49 |
+
if len(v) < 1:
|
| 50 |
+
raise ValueError("choice needs at least one option")
|
| 51 |
+
if len(v) > MAX_OPTIONS:
|
| 52 |
+
raise ValueError(f"choice supports at most {MAX_OPTIONS} options in one stage")
|
| 53 |
+
for k in v:
|
| 54 |
+
if not str(k).strip():
|
| 55 |
+
raise ValueError("option names must be non-empty")
|
| 56 |
+
return v
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class Score(BaseModel):
|
| 60 |
+
"""Rate the state against ordered levels; `criteria[i]` describes level i (low -> high)."""
|
| 61 |
+
|
| 62 |
+
type: Literal["score"] = "score"
|
| 63 |
+
instructions: str
|
| 64 |
+
criteria: List[str]
|
| 65 |
+
|
| 66 |
+
@field_validator("criteria")
|
| 67 |
+
@classmethod
|
| 68 |
+
def _check_criteria(cls, v):
|
| 69 |
+
if not (MIN_SCORE_LEVELS <= len(v) <= MAX_SCORE_LEVELS):
|
| 70 |
+
raise ValueError(f"score needs {MIN_SCORE_LEVELS}..{MAX_SCORE_LEVELS} levels")
|
| 71 |
+
return v
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class Point(BaseModel):
|
| 75 |
+
"""Vision variant: locate what `instructions` describes on image `image` -> normalised (x, y). Answer may abstain."""
|
| 76 |
+
|
| 77 |
+
type: Literal["point"] = "point"
|
| 78 |
+
instructions: str
|
| 79 |
+
image: Optional[str] = None # id of one of the request's images; None = the first image
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
Question = Union[Noul, Choice, Score, Point]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class NoulAnswer(BaseModel):
|
| 86 |
+
type: Literal["noul"] = "noul"
|
| 87 |
+
noul: float
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ChoiceAnswer(BaseModel):
|
| 91 |
+
type: Literal["choice"] = "choice"
|
| 92 |
+
choice: str
|
| 93 |
+
probabilities: Dict[str, float]
|
| 94 |
+
confidence: float
|
| 95 |
+
abstain: Optional[float] = None # OpenThai extension: p(none of the options); not in TypeSafe
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class ScoreAnswer(BaseModel):
|
| 99 |
+
type: Literal["score"] = "score"
|
| 100 |
+
score: float
|
| 101 |
+
legend: Dict[int, str]
|
| 102 |
+
probabilities: Dict[str, float]
|
| 103 |
+
confidence: float
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class PointCandidate(BaseModel):
|
| 107 |
+
x: float
|
| 108 |
+
y: float
|
| 109 |
+
p: float
|
| 110 |
+
bbox: Optional[List[float]] = None # normalised [x0, y0, x1, y1] of the token cluster
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class PointAnswer(BaseModel):
|
| 114 |
+
type: Literal["point"] = "point"
|
| 115 |
+
x: float
|
| 116 |
+
y: float
|
| 117 |
+
confidence: float # probability mass of the winning cluster
|
| 118 |
+
candidates: List[PointCandidate] = []
|
| 119 |
+
abstain: Optional[float] = None # p(target not on the image)
|
| 120 |
+
image: Optional[str] = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
Answer = Union[NoulAnswer, ChoiceAnswer, ScoreAnswer, PointAnswer]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class ImageInput(BaseModel):
|
| 127 |
+
"""One image attached to the state (vision variant). Give exactly one of data / url / path."""
|
| 128 |
+
|
| 129 |
+
id: str = "img"
|
| 130 |
+
data: Optional[str] = None # data URI or base64
|
| 131 |
+
url: Optional[str] = None
|
| 132 |
+
path: Optional[str] = None # local file (SDK use only; the server ignores it)
|
| 133 |
+
role: Optional[str] = None # screenshot | frame | photo | document | ...
|
| 134 |
+
detail: Literal["auto", "high"] = "auto"
|
| 135 |
+
|
| 136 |
+
@model_validator(mode="after")
|
| 137 |
+
def _one_source(self):
|
| 138 |
+
if sum(v is not None for v in (self.data, self.url, self.path)) != 1:
|
| 139 |
+
raise ValueError("image needs exactly one of data / url / path")
|
| 140 |
+
return self
|
| 141 |
+
|
| 142 |
+
@property
|
| 143 |
+
def source(self) -> str:
|
| 144 |
+
return self.data or self.url or self.path # type: ignore[return-value]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class Usage(BaseModel):
|
| 148 |
+
input_tokens: int
|
| 149 |
+
output_tokens: int = 0
|
| 150 |
+
permutations: int = 1 # OpenThai extension: number of option orders averaged (order-invariant mode)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class SystemOneRequest(BaseModel):
|
| 154 |
+
state: Union[str, Dict[str, Any], List[Any]]
|
| 155 |
+
model: str = "openthai-systemone"
|
| 156 |
+
questions: Dict[str, Question] = Field(discriminator=None)
|
| 157 |
+
# OpenThai extensions. order_invariant=True averages the answer over several option orders (removes position
|
| 158 |
+
# bias, ~2x latency); None = automatic (on for choice questions with > 10 options); permutations overrides the count.
|
| 159 |
+
order_invariant: Optional[bool] = None
|
| 160 |
+
permutations: Optional[int] = Field(default=None, ge=1, le=32)
|
| 161 |
+
# vision variant: up to 4 images; text-only models reject requests that carry images or point questions.
|
| 162 |
+
images: Optional[List[ImageInput]] = None
|
| 163 |
+
|
| 164 |
+
@model_validator(mode="after")
|
| 165 |
+
def _non_empty(self):
|
| 166 |
+
if not self.questions:
|
| 167 |
+
raise ValueError("at least one question is required")
|
| 168 |
+
if self.images is not None:
|
| 169 |
+
if len(self.images) > 4:
|
| 170 |
+
raise ValueError("at most 4 images per request")
|
| 171 |
+
ids = [im.id for im in self.images]
|
| 172 |
+
if len(set(ids)) != len(ids):
|
| 173 |
+
raise ValueError("image ids must be unique")
|
| 174 |
+
for im in self.images:
|
| 175 |
+
if im.path is not None:
|
| 176 |
+
raise ValueError("image 'path' is not accepted over the API; send data or url")
|
| 177 |
+
for qid, q in self.questions.items():
|
| 178 |
+
if isinstance(q, Point):
|
| 179 |
+
if not self.images:
|
| 180 |
+
raise ValueError(f"point question {qid!r} needs at least one image")
|
| 181 |
+
if q.image is not None and q.image not in {im.id for im in self.images}:
|
| 182 |
+
raise ValueError(f"point question {qid!r} references unknown image {q.image!r}")
|
| 183 |
+
return self
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class SystemOneResponse(BaseModel):
|
| 187 |
+
model: str
|
| 188 |
+
answers: Dict[str, Answer]
|
| 189 |
+
usage: Usage
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def parse_question(obj: Union[Question, Dict[str, Any]]) -> Question:
|
| 193 |
+
"""Accept a dict (raw JSON) or an already-typed question."""
|
| 194 |
+
if isinstance(obj, (Noul, Choice, Score, Point)):
|
| 195 |
+
return obj
|
| 196 |
+
t = obj.get("type")
|
| 197 |
+
if t == "noul":
|
| 198 |
+
return Noul(**obj)
|
| 199 |
+
if t == "choice":
|
| 200 |
+
return Choice(**obj)
|
| 201 |
+
if t == "score":
|
| 202 |
+
return Score(**obj)
|
| 203 |
+
if t == "point":
|
| 204 |
+
return Point(**obj)
|
| 205 |
+
raise ValueError(f"unknown question type: {t!r}")
|