Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- LICENSE +201 -0
- NOTICE +21 -0
- README.md +26 -7
- app.py +331 -0
- examples/images/scene.png +0 -0
- requirements.txt +6 -0
- src/spatial_jev/__init__.py +12 -0
- src/spatial_jev/hierarchy.py +162 -0
- src/spatial_jev/kev.py +749 -0
- src/spatial_jev/kev_native_qk.py +155 -0
- src/spatial_jev/kev_prefix.py +325 -0
- src/spatial_jev/numeric_tasks.py +71 -0
- src/spatial_jev/point_probability_map.py +125 -0
- src/spatial_jev/qwen35.py +352 -0
- src/spatial_jev/roi_replay.py +154 -0
- src/spatial_jev/schema.py +138 -0
- src/spatial_jev/training_alignment.py +56 -0
- src/spatial_jev/unified.py +357 -0
- src/spatial_jev/visual_token_pointing.py +118 -0
LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
https://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "{}"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright {yyyy} {name of copyright owner}
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
https://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
NOTICE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Jev-Spatial — Apache License, Version 2.0
|
| 2 |
+
|
| 3 |
+
Based on Molmo2-ER and Molmo2 by the Allen Institute for AI and contributors.
|
| 4 |
+
Model: https://huggingface.co/allenai/Molmo2-ER
|
| 5 |
+
Source: https://github.com/allenai/molmo2
|
| 6 |
+
Pinned revision: dab22564403d2607855bb1fffb0721285b445081
|
| 7 |
+
Upstream license: Apache License, Version 2.0 (retained in LICENSE).
|
| 8 |
+
|
| 9 |
+
The five native Python modules under spatial_jev/molmo2 are unmodified copies
|
| 10 |
+
of the model/processor implementation at the pinned revision. Jev-Spatial adds
|
| 11 |
+
a shared classification head, hierarchical inference, training and evaluation
|
| 12 |
+
code. The released weights merge the trained LoRA into Molmo2-ER and omit the
|
| 13 |
+
unused autoregressive vocabulary head. Configuration metadata is adapted to
|
| 14 |
+
load the implementation from the installed source package.
|
| 15 |
+
|
| 16 |
+
Jev / System One by TypeSafe is the project's conceptual inspiration.
|
| 17 |
+
This is an independent project, not an official TypeSafe implementation or
|
| 18 |
+
a reproduction of proprietary RLCD. Full acknowledgements are in README.md.
|
| 19 |
+
|
| 20 |
+
Training and benchmark media are not included. Their original attribution
|
| 21 |
+
and terms remain with their respective authors and distributors.
|
README.md
CHANGED
|
@@ -1,13 +1,32 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: MJev-Spatial
|
| 3 |
+
emoji: 🧭
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.30.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
short_description: Spatial VLM that decides in one forward pass
|
| 10 |
+
python_version: "3.12"
|
| 11 |
+
startup_duration_timeout: 30m
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# MJev-Spatial
|
| 15 |
+
|
| 16 |
+
Demo of **MJev-Spatial: System One Spatial Intelligence via Native Attention Matching**
|
| 17 |
+
([arXiv 2610.22276](https://arxiv.org/abs/2610.22276) · [GitHub](https://github.com/Fr0zenCrane/MJev-Spatial)).
|
| 18 |
+
|
| 19 |
+
MJev-Spatial is a 4B multimodal decision model built on Qwen3.5-4B. It turns a spatial
|
| 20 |
+
question into a finite candidate-selection problem and scores those candidates in a
|
| 21 |
+
**single forward pass** with no generated answer tokens, reusing the language backbone's
|
| 22 |
+
final full-attention layer's native query/key projections as the readout.
|
| 23 |
+
|
| 24 |
+
The demo exposes the three answer spaces from the paper:
|
| 25 |
+
|
| 26 |
+
- **Choice** — pick one of the supplied options (e.g. relative-direction questions).
|
| 27 |
+
- **Scalar** — estimate a nonnegative measurement in meters via an interval hierarchy.
|
| 28 |
+
- **Pointing** — point at a requested target in the image; the predicted point is
|
| 29 |
+
drawn on the image (the model makes three 3×3 region decisions, refining a crop).
|
| 30 |
+
|
| 31 |
+
Example image: `examples/images/scene.png` from the official
|
| 32 |
+
[MJev-Spatial repository](https://github.com/Fr0zenCrane/MJev-Spatial) (Apache-2.0).
|
app.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MJev-Spatial — one-forward-pass spatial decision demo.
|
| 2 |
+
|
| 3 |
+
Model: https://huggingface.co/Fr0zenCr4nE/MJev-Spatial-Qwen-3.5-4B
|
| 4 |
+
Paper: MJev-Spatial: System One Spatial Intelligence via Native Attention Matching
|
| 5 |
+
(arXiv 2610.22276). Inference follows the official scripts/predict.py path:
|
| 6 |
+
the model scores a finite candidate set with the language backbone's native
|
| 7 |
+
final-attention Q/K projections and returns the argmax decision — no text is generated.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 13 |
+
|
| 14 |
+
import spaces # noqa: E402 -- MUST precede torch / CUDA-touching imports
|
| 15 |
+
import json # noqa: E402
|
| 16 |
+
import sys # noqa: E402
|
| 17 |
+
import tempfile # noqa: E402
|
| 18 |
+
import time # noqa: E402
|
| 19 |
+
from pathlib import Path # noqa: E402
|
| 20 |
+
|
| 21 |
+
import gradio as gr # noqa: E402
|
| 22 |
+
import numpy as np # noqa: E402
|
| 23 |
+
import torch # noqa: E402
|
| 24 |
+
from PIL import Image, ImageDraw # noqa: E402
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent
|
| 27 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 28 |
+
|
| 29 |
+
# Vendored inference runtime (Apache-2.0) from github.com/Fr0zenCrane/MJev-Spatial
|
| 30 |
+
from spatial_jev.kev import KevBuilder, build_model, make_processor, predict_batch # noqa: E402
|
| 31 |
+
from spatial_jev.numeric_tasks import ANGLE_CODEBOOK, COUNT_CODEBOOK # noqa: E402
|
| 32 |
+
|
| 33 |
+
CHECKPOINT = "Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B"
|
| 34 |
+
MAX_SIDE = 512 # keep encode time and sequence length comfortable on ZeroGPU
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Model loading (module scope, eager .to("cuda") — intercepted by the spaces hijack)
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
ckpt_dir = CHECKPOINT # from_pretrained accepts repo ids directly
|
| 41 |
+
config = json.loads(Path(hf_hub_file(ckpt_dir, "config.json")).read_text()) if False else None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def hf_hub_file(repo_id, filename):
|
| 45 |
+
from huggingface_hub import hf_hub_download
|
| 46 |
+
return hf_hub_download(repo_id, filename)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
config = json.loads(hf_hub_file(ckpt_dir, "config.json").read_text())
|
| 50 |
+
config.update(
|
| 51 |
+
preserve_option_order=True,
|
| 52 |
+
max_image_tokens=1024,
|
| 53 |
+
max_sequence_length=max(8192, int(config.get("max_sequence_length", 4096))),
|
| 54 |
+
inference_language_dtype="bf16",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
print("[mjev] loading processor…", flush=True)
|
| 58 |
+
processor = make_processor(config)
|
| 59 |
+
print("[mjev] loading model…", flush=True)
|
| 60 |
+
builder = KevBuilder(processor, config)
|
| 61 |
+
MODEL = build_model(config, ckpt_dir, trainable=False)
|
| 62 |
+
MODEL = MODEL.to("cuda").eval()
|
| 63 |
+
print("[mjev] model ready", flush=True)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
# Helpers
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
|
| 70 |
+
def load_image(image, max_side=MAX_SIDE):
|
| 71 |
+
"""PIL RGB image scaled so neon-style huge uploads don't blow the token budget."""
|
| 72 |
+
if image is None:
|
| 73 |
+
raise gr.Error("Please provide an image.")
|
| 74 |
+
img = image if isinstance(image, Image.Image) else Image.open(image)
|
| 75 |
+
img = img.convert("RGB")
|
| 76 |
+
w, h = img.size
|
| 77 |
+
scale = max_side / max(w, h)
|
| 78 |
+
if scale < 1:
|
| 79 |
+
img = img.resize((max(1, round(w * scale)), max(1, round(h * scale))), Image.Resampling.BILINEAR)
|
| 80 |
+
return img
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def parse_options(text):
|
| 84 |
+
"""One option per line; 'id | text' optional — falls back to the text as id."""
|
| 85 |
+
options = []
|
| 86 |
+
for line in (text or "").strip().splitlines():
|
| 87 |
+
line = line.strip()
|
| 88 |
+
if not line:
|
| 89 |
+
continue
|
| 90 |
+
if "|" in line:
|
| 91 |
+
oid, otext = line.split("|", 1)
|
| 92 |
+
oid, otext = oid.strip(), otext.strip() or oid.strip()
|
| 93 |
+
else:
|
| 94 |
+
oid = otext = line
|
| 95 |
+
options.append({"id": oid, "text": otext})
|
| 96 |
+
if len(options) < 2:
|
| 97 |
+
raise gr.Error("Choice tasks need at least 2 options (one per line).")
|
| 98 |
+
if len(options) > config["max_choices"]:
|
| 99 |
+
raise gr.Error(f"Too many options — this checkpoint supports at most {config['max_choices']}.")
|
| 100 |
+
return options
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def draw_point(image, xy):
|
| 104 |
+
"""Overlay the predicted normalized point + crosshair on a copy of the image."""
|
| 105 |
+
img = image.copy()
|
| 106 |
+
w, h = img.size
|
| 107 |
+
x, y = xy[0] * w, xy[1] * h
|
| 108 |
+
draw = ImageDraw.Draw(img)
|
| 109 |
+
r = max(6, int(min(w, h) * 0.02))
|
| 110 |
+
for color, width in ((255, 255, 255, 255), (255, 70, 0, 255)):
|
| 111 |
+
draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=width)
|
| 112 |
+
draw.line([x - 1.6 * r, y, x + 1.6 * r, y], fill=color, width=width)
|
| 113 |
+
draw.line([x, y - 1.6 * r, x, y + 1.6 * r], fill=color, width=width)
|
| 114 |
+
return img
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def overlay_probability_grid(image, result):
|
| 118 |
+
"""Pointing: warm heatmap of the final-stage 9-way scores over the selected crop."""
|
| 119 |
+
if "logits" not in result or result.get("pointing_method") != "visual_tokens":
|
| 120 |
+
return None
|
| 121 |
+
probs = np.asarray(result.get("probabilities", []), dtype=np.float64)
|
| 122 |
+
if probs.size == 0:
|
| 123 |
+
return None
|
| 124 |
+
height, width = result.get("grid_hw", [1, 1])
|
| 125 |
+
if probs.shape != (height, width):
|
| 126 |
+
return None
|
| 127 |
+
heat = np.zeros((height, width, 4), dtype=np.uint8)
|
| 128 |
+
heat[..., 0] = 255
|
| 129 |
+
heat[..., 3] = (probs / probs.max() * 255).astype(np.uint8) if probs.max() > 0 else 0
|
| 130 |
+
heat_img = Image.fromarray(heat, "RGBA").resize(image.size, Image.Resampling.BILINEAR)
|
| 131 |
+
out = image.copy()
|
| 132 |
+
out.paste(heat_img, (0, 0), heat_img)
|
| 133 |
+
return out
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def describe_path(result, kind, option_map=None):
|
| 137 |
+
"""Human-readable trace of the decision path."""
|
| 138 |
+
lines = []
|
| 139 |
+
logits = result.get("logits") or []
|
| 140 |
+
path = result.get("path") or []
|
| 141 |
+
names = {
|
| 142 |
+
"top-center": "top-center", "top-left": "top-left", "top-right": "top-right",
|
| 143 |
+
"center": "center", "middle-left": "middle-left", "middle-right": "middle-right",
|
| 144 |
+
"bottom-center": "bottom-center", "bottom-left": "bottom-left",
|
| 145 |
+
"bottom-right": "bottom-right",
|
| 146 |
+
}
|
| 147 |
+
from spatial_jev.hierarchy import POINT_NAMES, decode_point, decode_scalar # local import
|
| 148 |
+
if kind == "choice":
|
| 149 |
+
if option_map:
|
| 150 |
+
lines.append(f"selected option: **{option_map[path[0]]}**" if path else "")
|
| 151 |
+
elif result.get("prediction") is not None:
|
| 152 |
+
lines.append(f"selected option: **{result['prediction']}**")
|
| 153 |
+
elif kind == "scalar":
|
| 154 |
+
if path:
|
| 155 |
+
edges = config["scalar_codebook"]["edges_m"]
|
| 156 |
+
if path[0] == 0:
|
| 157 |
+
lines.append("coarse stage: exactly zero")
|
| 158 |
+
elif path[0] == len(edges):
|
| 159 |
+
lines.append(f"coarse stage: greater than {edges[-1]:g} m (overflow)")
|
| 160 |
+
else:
|
| 161 |
+
from spatial_jev.hierarchy import scalar_interval, scalar_fine_bins
|
| 162 |
+
lo, hi = scalar_interval(path[0], config["scalar_codebook"])
|
| 163 |
+
lines.append(f"coarse stage: interval {lo:.6g} – {hi:.6g} m")
|
| 164 |
+
if len(path) > 1:
|
| 165 |
+
fine = scalar_fine_bins(path[0], config["scalar_codebook"])
|
| 166 |
+
stepw = (hi - lo) / fine
|
| 167 |
+
lines.append(f"fine stage: refined bin {path[1] + 1}/{fine} "
|
| 168 |
+
f"≈ {lo + path[1] * stepw:.6g} – {lo + (path[1] + 1) * stepw:.6g} m")
|
| 169 |
+
lines.append(f"decoded estimate: **{result['prediction']:.6g} m**")
|
| 170 |
+
elif kind == "point":
|
| 171 |
+
steps = ["whole image", *(f"zoom #{i}" for i in range(1, len(path)))]
|
| 172 |
+
for stage, (choice, scores) in enumerate(zip(path, logits)):
|
| 173 |
+
label = names.get(POINT_NAMES[choice], POINT_NAMES[choice])
|
| 174 |
+
best = scores[choice] if stage < len(scores) and choice < len(scores) else float("nan")
|
| 175 |
+
lines.append(f"stage {stage + 1} ({steps[stage] if stage < len(steps) else ''}): "
|
| 176 |
+
f"region **{label}** (score {best:.3f})")
|
| 177 |
+
if kind == "point" and result.get("prediction"):
|
| 178 |
+
x, y = result["prediction"]
|
| 179 |
+
lines.append(f"decoded point: **({x:.3f}, {y:.3f})** normalized "
|
| 180 |
+
f"(origin top-left, x→right, y→down)")
|
| 181 |
+
return "\n".join(f"- {l}" for l in lines if l)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# Inference endpoint
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
@spaces.GPU(duration=75)
|
| 189 |
+
def run_spatial(image, task, question, options_text, quantity, unit, progress=gr.Progress()):
|
| 190 |
+
"""Answer a spatial question about an image in one forward pass per decision stage.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
image: input image (PIL).
|
| 194 |
+
task: one of "choice" | "scalar" | "point".
|
| 195 |
+
question: the spatial question about the image.
|
| 196 |
+
options_text: for choice tasks, one option per line ("id | text" accepted).
|
| 197 |
+
quantity: for scalar tasks, what is being estimated (e.g. "distance").
|
| 198 |
+
unit: scalar unit — "m", "count" or "deg".
|
| 199 |
+
progress: Gradio progress bar.
|
| 200 |
+
Returns:
|
| 201 |
+
(answer markdown, decision-path markdown, annotated image, raw JSON result)
|
| 202 |
+
"""
|
| 203 |
+
t0 = time.perf_counter()
|
| 204 |
+
img = load_image(image)
|
| 205 |
+
if not (question or "").strip():
|
| 206 |
+
raise gr.Error("Please provide a question.")
|
| 207 |
+
|
| 208 |
+
question = question.strip()
|
| 209 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 210 |
+
path = Path(tmp) / "input.png"
|
| 211 |
+
img.save(path)
|
| 212 |
+
if task == "choice":
|
| 213 |
+
space = {"kind": "choice", "options": parse_options(options_text)}
|
| 214 |
+
elif task == "scalar":
|
| 215 |
+
space = {"kind": "scalar", "quantity": (quantity or "value").strip() or "value",
|
| 216 |
+
"unit": unit}
|
| 217 |
+
else:
|
| 218 |
+
space = {"kind": "point", "coordinate_system": "normalized_xy", "num_points": 1}
|
| 219 |
+
request = {"id": "demo-request", "input": {
|
| 220 |
+
"media": [{"kind": "image", "uri": str(path)}],
|
| 221 |
+
"question": question,
|
| 222 |
+
"answer_space": space,
|
| 223 |
+
}}
|
| 224 |
+
result = predict_batch(MODEL, builder, [request], torch.device("cuda"))[0]
|
| 225 |
+
|
| 226 |
+
prediction = result["prediction"]
|
| 227 |
+
if task == "point":
|
| 228 |
+
point = prediction[0] if isinstance(prediction, list) else prediction
|
| 229 |
+
overlay = draw_point(img, point)
|
| 230 |
+
else:
|
| 231 |
+
overlay = img.copy()
|
| 232 |
+
|
| 233 |
+
if task == "choice":
|
| 234 |
+
answer = str(prediction)
|
| 235 |
+
elif task == "scalar":
|
| 236 |
+
label = {"m": "m", "count": "", "deg": "°"}[unit]
|
| 237 |
+
answer = f"{prediction:g}{label}" if unit != "count" else f"{int(prediction)} {quantity or 'objects'}"
|
| 238 |
+
else:
|
| 239 |
+
answer = f"point (x, y) = ({point[0]:.3f}, {point[1]:.3f})"
|
| 240 |
+
|
| 241 |
+
trace = describe_path(result, task)
|
| 242 |
+
seconds = time.perf_counter() - t0
|
| 243 |
+
summary = f"## {answer}\n\n*(one forward pass per decision stage, {seconds:.1f}s)*"
|
| 244 |
+
raw = json.dumps({"prediction": result.get("prediction"), "path": result.get("path"),
|
| 245 |
+
"logits": result.get("logits")}, ensure_ascii=False)
|
| 246 |
+
return summary, trace, overlay, raw
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ---------------------------------------------------------------------------
|
| 250 |
+
# UI
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
CSS = """
|
| 254 |
+
#col-container { max-width: 1100px; margin: 0 auto; }
|
| 255 |
+
.dark .gradio-container { color: var(--body-text-color); }
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
with gr.Blocks(theme=gr.themes.Citrus(), css=CSS, title="MJev-Spatial") as demo:
|
| 259 |
+
with gr.Column(elem_id="col-container"):
|
| 260 |
+
gr.Markdown(
|
| 261 |
+
"# 🧭 MJev-Spatial\n"
|
| 262 |
+
"Spatial intelligence via **native attention matching** — a 4B VLM that "
|
| 263 |
+
"answers spatial questions as a single candidate-scoring decision, with no "
|
| 264 |
+
"generated answer tokens. [paper](https://arxiv.org/abs/2610.22276) · "
|
| 265 |
+
"[code](https://github.com/Fr0zenCrane/MJev-Spatial) · "
|
| 266 |
+
"[checkpoint](https://huggingface.co/Fr0zencr4nE/MJev-Spatial-Qwen-3.5-4B)"
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
with gr.Row():
|
| 270 |
+
with gr.Column(scale=1):
|
| 271 |
+
image_in = gr.Image(type="pil", label="Image", height=300)
|
| 272 |
+
task = gr.Radio(
|
| 273 |
+
["choice", "scalar", "point"], value="choice", label="Answer space",
|
| 274 |
+
info="choice: pick an option · scalar: estimate a value · point: locate a target",
|
| 275 |
+
)
|
| 276 |
+
question = gr.Textbox(
|
| 277 |
+
label="Question", placeholder="e.g. Where is the red square relative to the blue circle?",
|
| 278 |
+
value="Where is the red square relative to the blue circle?",
|
| 279 |
+
)
|
| 280 |
+
options_text = gr.Textbox(
|
| 281 |
+
label="Options (choice only) — one per line, 'id | text' optional",
|
| 282 |
+
value="left | left\nright | right", lines=3,
|
| 283 |
+
)
|
| 284 |
+
quantity = gr.Textbox(
|
| 285 |
+
label="Quantity (scalar only)", value="distance",
|
| 286 |
+
info="What is being estimated, e.g. distance, width, height.",
|
| 287 |
+
)
|
| 288 |
+
unit = gr.Radio(["m", "count", "deg"], value="m", label="Unit (scalar only)")
|
| 289 |
+
run_btn = gr.Button("Decide", variant="primary")
|
| 290 |
+
|
| 291 |
+
with gr.Column(scale=1):
|
| 292 |
+
answer_md = gr.Markdown(label="Answer")
|
| 293 |
+
annotated = gr.Image(label="Predicted point (pointing tasks)", height=300)
|
| 294 |
+
trace_md = gr.Markdown("Decision path will appear here.")
|
| 295 |
+
with gr.Accordion("Raw result (JSON)", open=False):
|
| 296 |
+
raw_json = gr.JSON(label="scores / path")
|
| 297 |
+
|
| 298 |
+
gr.Examples(
|
| 299 |
+
examples=[
|
| 300 |
+
["examples/images/scene.png", "choice",
|
| 301 |
+
"Where is the red square relative to the blue circle?",
|
| 302 |
+
"left | left\nright | right", "distance", "m"],
|
| 303 |
+
["examples/images/scene.png", "scalar",
|
| 304 |
+
"What is the approximate distance between the two objects?",
|
| 305 |
+
"", "distance", "m"],
|
| 306 |
+
["examples/images/scene.png", "point",
|
| 307 |
+
"Point to the blue circle.", "", "distance", "m"],
|
| 308 |
+
],
|
| 309 |
+
inputs=[image_in, task, question, options_text, quantity, unit],
|
| 310 |
+
outputs=[answer_md, trace_md, annotated, raw_json],
|
| 311 |
+
fn=run_spatial,
|
| 312 |
+
label="Official examples (scene.png from the MJev-Spatial repo)",
|
| 313 |
+
cache_examples=True,
|
| 314 |
+
cache_mode="lazy",
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
gr.Markdown(
|
| 318 |
+
"Pointing decodes three 3×3 region decisions into a normalized point; the "
|
| 319 |
+
"score of each stage's picked region is shown in the decision path. "
|
| 320 |
+
"Scalar questions are answered through an interval hierarchy over meters "
|
| 321 |
+
"(or an integer count / degree codebook)."
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
run_btn.click(
|
| 325 |
+
run_spatial,
|
| 326 |
+
inputs=[image_in, task, question, options_text, quantity, unit],
|
| 327 |
+
outputs=[answer_md, trace_md, annotated, raw_json],
|
| 328 |
+
api_name="decide",
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
demo.launch(mcp_server=True)
|
examples/images/scene.png
ADDED
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
transformers==5.17.0
|
| 2 |
+
accelerate
|
| 3 |
+
numpy
|
| 4 |
+
pillow
|
| 5 |
+
safetensors
|
| 6 |
+
sentencepiece
|
src/spatial_jev/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Jev-Spatial: a shared classifier for structured spatial decisions."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
| 4 |
+
|
| 5 |
+
__all__ = ["JevSpatial"]
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def __getattr__(name):
|
| 9 |
+
if name == "JevSpatial":
|
| 10 |
+
from .inference import JevSpatial
|
| 11 |
+
return JevSpatial
|
| 12 |
+
raise AttributeError(name)
|
src/spatial_jev/hierarchy.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic scalar/point classification codebooks and visual ROI geometry."""
|
| 2 |
+
|
| 3 |
+
import bisect
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
# User-specified order: 正上、左上、右上、正中、左中、右中、正下、左下、右下.
|
| 9 |
+
POINT_NAMES = ("top-center", "top-left", "top-right", "center", "middle-left",
|
| 10 |
+
"middle-right", "bottom-center", "bottom-left", "bottom-right")
|
| 11 |
+
POINT_RC = ((0, 1), (0, 0), (0, 2), (1, 1), (1, 0), (1, 2), (2, 1), (2, 0), (2, 2))
|
| 12 |
+
ROOT_BOX = (0.0, 0.0, 1.0, 1.0)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def child_box(box, choice):
|
| 16 |
+
if not 0 <= choice < 9:
|
| 17 |
+
raise ValueError("Invalid nine-way choice")
|
| 18 |
+
x0, y0, x1, y1 = box
|
| 19 |
+
row, col = POINT_RC[choice]
|
| 20 |
+
w, h = (x1 - x0) / 3, (y1 - y0) / 3
|
| 21 |
+
return (x0 + col * w, y0 + row * h, x0 + (col + 1) * w, y0 + (row + 1) * h)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def point_label(xy, box):
|
| 25 |
+
x0, y0, x1, y1 = box
|
| 26 |
+
x, y = xy
|
| 27 |
+
if not all(math.isfinite(v) for v in xy) or not (x0-1e-9 <= x <= x1+1e-9
|
| 28 |
+
and y0-1e-9 <= y <= y1+1e-9):
|
| 29 |
+
raise ValueError("Point outside parent")
|
| 30 |
+
col = min(2, max(0, math.floor((x - x0) / (x1 - x0) * 3 + 1e-12)))
|
| 31 |
+
row = min(2, max(0, math.floor((y - y0) / (y1 - y0) * 3 + 1e-12)))
|
| 32 |
+
return POINT_RC.index((row, col))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def point_path(xy, depth=3):
|
| 36 |
+
box, path = ROOT_BOX, []
|
| 37 |
+
for _ in range(depth):
|
| 38 |
+
choice = point_label(xy, box)
|
| 39 |
+
path.append(choice)
|
| 40 |
+
box = child_box(box, choice)
|
| 41 |
+
return path
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def decode_point(path):
|
| 45 |
+
box = ROOT_BOX
|
| 46 |
+
for choice in path:
|
| 47 |
+
box = child_box(box, choice)
|
| 48 |
+
return [(box[0] + box[2]) / 2, (box[1] + box[3]) / 2]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def scalar_fine_bins(root_choice, codebook):
|
| 52 |
+
"""Refinement cardinality is fixed by the selected root, never by the target."""
|
| 53 |
+
if not 1 <= root_choice < len(codebook["edges_m"]):
|
| 54 |
+
raise ValueError("Zero/overflow has no refinement candidates")
|
| 55 |
+
by_root = codebook.get("fine_bins_by_root")
|
| 56 |
+
if by_root is not None:
|
| 57 |
+
if len(by_root) != len(codebook["edges_m"]) - 1:
|
| 58 |
+
raise ValueError("Refinement counts must cover every positive root")
|
| 59 |
+
fine = by_root[root_choice - 1]
|
| 60 |
+
else:
|
| 61 |
+
fine = codebook["fine_bins"]
|
| 62 |
+
if type(fine) is not int or not 1 <= fine <= 32:
|
| 63 |
+
raise ValueError("Refinement counts must be integers in [1, 32]")
|
| 64 |
+
return fine
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def scalar_path(value, codebook):
|
| 68 |
+
edges = codebook["edges_m"]
|
| 69 |
+
if not math.isfinite(value) or value < 0:
|
| 70 |
+
raise ValueError("Invalid nonnegative scalar")
|
| 71 |
+
if value == 0:
|
| 72 |
+
return [0]
|
| 73 |
+
if value > edges[-1]:
|
| 74 |
+
return [len(edges)] # Explicit overflow, never silently clipped.
|
| 75 |
+
bucket = min(bisect.bisect_right(edges, value) - 1, len(edges) - 2)
|
| 76 |
+
fine = scalar_fine_bins(bucket + 1, codebook)
|
| 77 |
+
lo, hi = edges[bucket:bucket+2]
|
| 78 |
+
local = min(fine - 1, max(0, int((value - lo) / (hi - lo) * fine)))
|
| 79 |
+
return [bucket + 1, local]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def scalar_interval(root_choice, codebook):
|
| 83 |
+
edges = codebook["edges_m"]
|
| 84 |
+
if not 1 <= root_choice < len(edges):
|
| 85 |
+
raise ValueError("Zero/overflow has no refinement interval")
|
| 86 |
+
return edges[root_choice - 1], edges[root_choice]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def decode_scalar(path, codebook):
|
| 90 |
+
if path[0] == 0:
|
| 91 |
+
return 0.0
|
| 92 |
+
if path[0] == len(codebook["edges_m"]):
|
| 93 |
+
return codebook["overflow_representative_m"]
|
| 94 |
+
fine = scalar_fine_bins(path[0], codebook)
|
| 95 |
+
if len(path) != 2 or not 0 <= path[1] < fine:
|
| 96 |
+
raise ValueError("Missing/invalid scalar refinement")
|
| 97 |
+
lo, hi = scalar_interval(path[0], codebook)
|
| 98 |
+
return lo + (path[1] + .5) * (hi - lo) / fine
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def decode_scalar_expectation(root_choice, fine_logits, codebook):
|
| 102 |
+
"""Conditional mean of fine-bin centers, T=1, within one argmax coarse root.
|
| 103 |
+
|
| 104 |
+
No averaging across coarse roots and no target-dependent decisions. Reserved
|
| 105 |
+
zero/overflow roots retain their existing exact representatives.
|
| 106 |
+
"""
|
| 107 |
+
if root_choice in (0, len(codebook["edges_m"])):
|
| 108 |
+
if len(fine_logits):
|
| 109 |
+
raise ValueError("Zero/overflow must not have a second-stage distribution")
|
| 110 |
+
return decode_scalar([root_choice], codebook)
|
| 111 |
+
fine = scalar_fine_bins(root_choice, codebook)
|
| 112 |
+
logits = np.asarray(fine_logits, dtype=np.float64)
|
| 113 |
+
if logits.shape != (fine,) or not np.isfinite(logits).all():
|
| 114 |
+
raise ValueError("Expectation requires exactly the finite valid fine logits")
|
| 115 |
+
probabilities = np.exp(logits - logits.max())
|
| 116 |
+
probabilities /= probabilities.sum()
|
| 117 |
+
centers = np.asarray([decode_scalar([root_choice, i], codebook) for i in range(fine)])
|
| 118 |
+
return float(probabilities @ centers)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def visual_token_boxes(metadata, image_grids, image_num_crops, patches_per_crop):
|
| 122 |
+
"""Map pooled tokens to normalized original-image support boxes, including padding.
|
| 123 |
+
|
| 124 |
+
Uses native subpatch_mapping (stretched stitched crop grid), not token-index slicing.
|
| 125 |
+
Each image's global crop and high-resolution crop mapping have their own geometry.
|
| 126 |
+
"""
|
| 127 |
+
pooling = np.asarray(metadata["token_pooling"])
|
| 128 |
+
offset, token_offset, result = 0, 0, []
|
| 129 |
+
side = math.isqrt(patches_per_crop)
|
| 130 |
+
if side * side != patches_per_crop:
|
| 131 |
+
raise ValueError("Expected square ViT crop patch grid")
|
| 132 |
+
for image_i, (grid, crops) in enumerate(zip(image_grids, image_num_crops)):
|
| 133 |
+
mapping = np.asarray(metadata["subpatch_mapping"][image_i])
|
| 134 |
+
h, w = mapping.shape
|
| 135 |
+
patch_boxes = {}
|
| 136 |
+
for row in range(side):
|
| 137 |
+
for col in range(side):
|
| 138 |
+
patch_boxes[offset + row * side + col] = (
|
| 139 |
+
col / side, row / side, (col + 1) / side, (row + 1) / side)
|
| 140 |
+
for row in range(h):
|
| 141 |
+
for col in range(w):
|
| 142 |
+
patch_boxes[int(mapping[row, col])] = (col / w, row / h,
|
| 143 |
+
(col + 1) / w, (row + 1) / h)
|
| 144 |
+
count = int(grid[0] * grid[1] + grid[2] * grid[3])
|
| 145 |
+
for indices in pooling[token_offset:token_offset + count]:
|
| 146 |
+
boxes = [patch_boxes[int(i)] for i in indices if i >= 0]
|
| 147 |
+
if not boxes:
|
| 148 |
+
raise ValueError("Pooled visual token has no valid support")
|
| 149 |
+
result.append((image_i, min(b[0] for b in boxes), min(b[1] for b in boxes),
|
| 150 |
+
max(b[2] for b in boxes), max(b[3] for b in boxes)))
|
| 151 |
+
token_offset += count
|
| 152 |
+
offset += int(crops) * patches_per_crop
|
| 153 |
+
if token_offset != len(pooling):
|
| 154 |
+
raise ValueError("Unmapped visual tokens")
|
| 155 |
+
return np.array(result, dtype=np.float32)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def roi_visible(boxes, roi, image_index=0):
|
| 159 |
+
boxes = np.asarray(boxes)
|
| 160 |
+
x0, y0, x1, y1 = roi
|
| 161 |
+
return ((boxes[:, 0] == image_index) & (boxes[:, 1] < x1) & (boxes[:, 3] > x0)
|
| 162 |
+
& (boxes[:, 2] < y1) & (boxes[:, 4] > y0))
|
src/spatial_jev/kev.py
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multimodal KEV decisions using the existing spatial schemas and controllers.
|
| 2 |
+
|
| 3 |
+
Pointer formula and delimiters follow jaredpalmer/kev@f1535963 (Apache-2.0).
|
| 4 |
+
Each original QA is a private causal branch, including its successive crops.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import math
|
| 10 |
+
import random
|
| 11 |
+
import re
|
| 12 |
+
from contextlib import nullcontext
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
from torch import nn
|
| 18 |
+
|
| 19 |
+
from .hierarchy import ROOT_BOX, child_box, decode_point, decode_scalar_expectation, point_path, scalar_fine_bins
|
| 20 |
+
from .numeric_tasks import ANGLE_CODEBOOK, COUNT_CODEBOOK, codebook_for, decode_numeric, numeric_done, numeric_path
|
| 21 |
+
from .qwen35 import LORA_TARGETS, configure_full_language, sample_seed, train_mode
|
| 22 |
+
from .qwen35 import make_processor as make_processor # noqa: PLC0414 -- explicit public re-export
|
| 23 |
+
from .roi_replay import (
|
| 24 |
+
EMPTY_ROI_IMAGE,
|
| 25 |
+
KV_REPLAY,
|
| 26 |
+
REPLAY_VARIANTS,
|
| 27 |
+
expand_replay_inputs,
|
| 28 |
+
kv_replay_hidden,
|
| 29 |
+
)
|
| 30 |
+
from .unified import UnifiedBuilder
|
| 31 |
+
from .visual_token_pointing import (
|
| 32 |
+
ORIGINAL_IMAGE,
|
| 33 |
+
VISUAL_TOKENS,
|
| 34 |
+
decode_visual_points,
|
| 35 |
+
image_token_candidates,
|
| 36 |
+
target_cells,
|
| 37 |
+
validate_pointing_config,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
UPSTREAM_REVISION = "f1535963cea021439370c23127bc970b6788e730"
|
| 41 |
+
SPECIAL = ("<|fim_prefix|>", "<|fim_middle|>", "<|box_start|>", "<|box_end|>", "<|fim_suffix|>")
|
| 42 |
+
IMAGE = "<|vision_start|><|image_pad|><|vision_end|>"
|
| 43 |
+
CONTROL = re.compile(r"<\|([A-Za-z0-9_]+)\|>")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def escape(text):
|
| 47 |
+
return CONTROL.sub(r"<¦\1¦>", text)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@lru_cache(maxsize=100000)
|
| 51 |
+
def _file_hash(path, size, mtime_ns):
|
| 52 |
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def media_key(media):
|
| 56 |
+
# Content identities, not image-pad token IDs or mutable paths, identify images.
|
| 57 |
+
path = Path(media["uri"])
|
| 58 |
+
stat = path.stat()
|
| 59 |
+
return _file_hash(str(path), stat.st_size, stat.st_mtime_ns)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def state_key(inp):
|
| 63 |
+
return tuple(media_key(m) for m in inp["media"])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def to_device(value, device):
|
| 67 |
+
if isinstance(value, torch.Tensor):
|
| 68 |
+
return value.to(device, non_blocking=True)
|
| 69 |
+
if isinstance(value, dict):
|
| 70 |
+
return {k: to_device(v, device) for k, v in value.items()}
|
| 71 |
+
return value
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class PointerHead(nn.Module):
|
| 75 |
+
"""KEV's affine query/key projections and scaled dot product, in FP32."""
|
| 76 |
+
|
| 77 |
+
def __init__(self, hidden_size, pointer_dim=256):
|
| 78 |
+
super().__init__()
|
| 79 |
+
self.q = nn.Linear(hidden_size, pointer_dim)
|
| 80 |
+
self.k = nn.Linear(hidden_size, pointer_dim)
|
| 81 |
+
self.scale = 1 / math.sqrt(pointer_dim)
|
| 82 |
+
|
| 83 |
+
def forward(self, decisions, options, owner, slot, counts):
|
| 84 |
+
with torch.autocast(device_type=decisions.device.type, enabled=False):
|
| 85 |
+
scores = (self.k(options.float()) * self.q(decisions.float())[owner]).sum(-1)
|
| 86 |
+
scores = scores * self.scale
|
| 87 |
+
logits = scores.new_full((len(decisions), int(counts.max())), -torch.inf)
|
| 88 |
+
return logits.index_put((owner, slot), scores)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class KevBuilder(UnifiedBuilder):
|
| 92 |
+
def __init__(self, processor, config):
|
| 93 |
+
validate_pointing_config(config)
|
| 94 |
+
if (config.get("numeric_codebooks") is not None
|
| 95 |
+
and config["numeric_codebooks"] != {"count": COUNT_CODEBOOK, "deg": ANGLE_CODEBOOK}):
|
| 96 |
+
raise ValueError("Checkpoint numeric codebooks differ from this implementation")
|
| 97 |
+
self.processor, self.config = processor, config
|
| 98 |
+
self.codebook, self.variant = config["scalar_codebook"], config.get("point_variant", "crop_refill")
|
| 99 |
+
if self.codebook.get("fine_bins_by_root") is not None:
|
| 100 |
+
for root in range(1, len(self.codebook["edges_m"])):
|
| 101 |
+
if scalar_fine_bins(root, self.codebook) > config["max_choices"]:
|
| 102 |
+
raise ValueError("Scalar refinement exceeds model candidate budget")
|
| 103 |
+
self.visual_token_pointing = self.variant == VISUAL_TOKENS
|
| 104 |
+
self.markers = [processor.tokenizer.convert_tokens_to_ids(t) for t in SPECIAL]
|
| 105 |
+
if len(set(self.markers)) != 5:
|
| 106 |
+
raise ValueError("KEV requires five distinct existing delimiter tokens")
|
| 107 |
+
|
| 108 |
+
def stage(self, inp, seed, box=ROOT_BOX, level=0, scalar_root=None):
|
| 109 |
+
task = inp["answer_space"]["kind"]
|
| 110 |
+
if task == "point" and self.visual_token_pointing:
|
| 111 |
+
instruction = f"Request: {inp['question']}\nLocate the requested target in the image."
|
| 112 |
+
# No artificial region options: the candidates are image-token positions.
|
| 113 |
+
return SPECIAL[1] + escape(instruction) + SPECIAL[4], 0, []
|
| 114 |
+
mapping = []
|
| 115 |
+
if task == "choice":
|
| 116 |
+
options = list(inp["answer_space"]["options"])
|
| 117 |
+
if not (self.config.get("preserve_option_order", False)
|
| 118 |
+
or inp["answer_space"].get("preserve_option_order", False)):
|
| 119 |
+
random.Random(seed).shuffle(options)
|
| 120 |
+
mapping = [o["id"] for o in options]
|
| 121 |
+
instruction = f"Question: {inp['question']}\nSelect exactly one option."
|
| 122 |
+
options = [o["text"] for o in options]
|
| 123 |
+
else:
|
| 124 |
+
# Use the existing authoritative spatial prompts, including interval semantics.
|
| 125 |
+
prompt = (
|
| 126 |
+
self.point_prompt(inp, box, level)
|
| 127 |
+
if task == "point"
|
| 128 |
+
else self.scalar_prompt(inp, scalar_root)[0]
|
| 129 |
+
)
|
| 130 |
+
instruction, tail = prompt.rsplit("\nOptions:\n", 1)
|
| 131 |
+
option_text, suffix = tail.rsplit("\nSelect exactly one option.", 1)
|
| 132 |
+
if suffix:
|
| 133 |
+
raise ValueError("Unexpected spatial prompt suffix")
|
| 134 |
+
options = [line.split(". ", 1)[1] for line in option_text.splitlines()]
|
| 135 |
+
instruction += "\nSelect exactly one option."
|
| 136 |
+
text = SPECIAL[1] + escape(instruction)
|
| 137 |
+
text += "".join(SPECIAL[2] + escape(o) + SPECIAL[3] for o in options)
|
| 138 |
+
return text + SPECIAL[4], len(options), mapping
|
| 139 |
+
|
| 140 |
+
def initial_spec(self, inp, identity):
|
| 141 |
+
if self.variant in (VISUAL_TOKENS, ORIGINAL_IMAGE, *REPLAY_VARIANTS) and inp["answer_space"]["kind"] == "point":
|
| 142 |
+
if len(inp["media"]) != 1 or inp["media"][0].get("kind", "image") != "image":
|
| 143 |
+
raise ValueError("Original-image / visual-token pointing requires exactly one still image")
|
| 144 |
+
if inp["answer_space"].get("coordinate_system", "normalized_xy") != "normalized_xy":
|
| 145 |
+
raise ValueError("Original-image / visual-token pointing requires normalized_xy coordinates")
|
| 146 |
+
text, count, mapping = self.stage(inp, sample_seed(identity, self.config["seed"]))
|
| 147 |
+
keys = state_key(inp)
|
| 148 |
+
return {
|
| 149 |
+
"text": SPECIAL[0] + IMAGE * len(keys) + text,
|
| 150 |
+
"images": self.load_images(inp),
|
| 151 |
+
"image_keys": list(keys),
|
| 152 |
+
"state_key": keys,
|
| 153 |
+
"sample_id": identity,
|
| 154 |
+
"counts": [count],
|
| 155 |
+
"mapping": mapping,
|
| 156 |
+
"box": ROOT_BOX,
|
| 157 |
+
"path": [],
|
| 158 |
+
"input": inp,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
def advance(self, spec, choice):
|
| 162 |
+
inp, path = spec["input"], spec["path"]
|
| 163 |
+
task = inp["answer_space"]["kind"]
|
| 164 |
+
if task == "point" and self.visual_token_pointing:
|
| 165 |
+
raise ValueError("Visual-token pointing does not advance or crop")
|
| 166 |
+
extra = ""
|
| 167 |
+
if task == "point":
|
| 168 |
+
spec["box"] = child_box(spec["box"], choice)
|
| 169 |
+
if self.variant in REPLAY_VARIANTS:
|
| 170 |
+
spec.setdefault('roi_boxes', []).append(spec['box'])
|
| 171 |
+
extra = EMPTY_ROI_IMAGE
|
| 172 |
+
elif self.variant != ORIGINAL_IMAGE:
|
| 173 |
+
spec["images"].append(self.crop(spec["images"][0], spec["box"]))
|
| 174 |
+
spec["image_keys"].append(spec["state_key"][0] + ":crop:" + repr(spec["box"]))
|
| 175 |
+
extra = IMAGE
|
| 176 |
+
text, count, _ = self.stage(
|
| 177 |
+
inp,
|
| 178 |
+
self.config["seed"],
|
| 179 |
+
spec["box"],
|
| 180 |
+
len(path),
|
| 181 |
+
scalar_root=path[0] if task == "scalar" else None,
|
| 182 |
+
)
|
| 183 |
+
spec["text"] += f"Selected option: {choice + 1}." + extra + text
|
| 184 |
+
spec["counts"].append(count)
|
| 185 |
+
|
| 186 |
+
def training_spec(self, row):
|
| 187 |
+
spec = self.initial_spec(row["input"], row["sample_id"])
|
| 188 |
+
kind = row["input"]["answer_space"]["kind"]
|
| 189 |
+
target = row["target"]
|
| 190 |
+
if kind == "point" and self.visual_token_pointing:
|
| 191 |
+
# Labels stay out of text/images; cell indices depend on the processor's grid.
|
| 192 |
+
spec.update(target_points=target["points"], weight=row.get("_weight", 1.0))
|
| 193 |
+
return spec
|
| 194 |
+
path = (
|
| 195 |
+
[spec["mapping"].index(target["option_id"])]
|
| 196 |
+
if kind == "choice"
|
| 197 |
+
else numeric_path(target["value"], codebook_for(row["input"], self.codebook))
|
| 198 |
+
if kind == "scalar"
|
| 199 |
+
else point_path(target["points"][0])
|
| 200 |
+
)
|
| 201 |
+
for choice in path[:-1]:
|
| 202 |
+
spec["path"].append(choice)
|
| 203 |
+
self.advance(spec, choice)
|
| 204 |
+
spec.update(targets=path, weight=row.get("_weight", 1.0))
|
| 205 |
+
return spec
|
| 206 |
+
|
| 207 |
+
def encode_specs(self, specs, training=True):
|
| 208 |
+
encoded = dict(
|
| 209 |
+
self.processor(
|
| 210 |
+
text=[s["text"] for s in specs],
|
| 211 |
+
images=[im for s in specs for im in s["images"]],
|
| 212 |
+
padding=True,
|
| 213 |
+
add_special_tokens=False,
|
| 214 |
+
return_tensors="pt",
|
| 215 |
+
device="cpu",
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
encoded.pop("token_type_ids", None)
|
| 219 |
+
if self.variant in REPLAY_VARIANTS:
|
| 220 |
+
encoded = expand_replay_inputs(self, specs, encoded)
|
| 221 |
+
lengths = encoded["attention_mask"].sum(-1).tolist()
|
| 222 |
+
if max(lengths) > self.config["max_sequence_length"]:
|
| 223 |
+
raise ValueError(f"KEV sequence budget exceeded without truncation: {lengths}")
|
| 224 |
+
if "mm_token_type_ids" not in encoded:
|
| 225 |
+
encoded["mm_token_type_ids"] = (
|
| 226 |
+
encoded["input_ids"]
|
| 227 |
+
== self.processor.tokenizer.convert_tokens_to_ids("<|image_pad|>")
|
| 228 |
+
).long()
|
| 229 |
+
positions, option_positions, owner, slot, counts, targets, weights = (
|
| 230 |
+
[],
|
| 231 |
+
[],
|
| 232 |
+
[],
|
| 233 |
+
[],
|
| 234 |
+
[],
|
| 235 |
+
[],
|
| 236 |
+
[],
|
| 237 |
+
)
|
| 238 |
+
prefix_lengths = []
|
| 239 |
+
image_cursor = 0
|
| 240 |
+
visual_owners, visual_slots, visual_mass = [], [], []
|
| 241 |
+
has_visual = False
|
| 242 |
+
for row, spec in enumerate(specs):
|
| 243 |
+
ids = encoded["input_ids"][row, : lengths[row]].tolist()
|
| 244 |
+
prefix_lengths.append(ids.index(self.markers[1]))
|
| 245 |
+
ends = [i for i, t in enumerate(ids) if t == self.markers[4]]
|
| 246 |
+
opt_ends = [i for i, t in enumerate(ids) if t == self.markers[3]]
|
| 247 |
+
first_grid = encoded["image_grid_thw"][image_cursor]
|
| 248 |
+
image_cursor += len(spec["images"])
|
| 249 |
+
if self.visual_token_pointing and spec["input"]["answer_space"]["kind"] == "point":
|
| 250 |
+
if len(ends) != 1 or opt_ends or len(spec["images"]) != 1:
|
| 251 |
+
raise ValueError("Visual pointing must have one query, one image and no text candidates")
|
| 252 |
+
candidates, shape = image_token_candidates(
|
| 253 |
+
ids, self.processor.tokenizer.convert_tokens_to_ids("<|image_pad|>"),
|
| 254 |
+
first_grid.tolist(), self.processor.image_processor.merge_size,
|
| 255 |
+
)
|
| 256 |
+
if candidates[-1] >= prefix_lengths[-1] or prefix_lengths[-1] >= ends[0]:
|
| 257 |
+
raise ValueError("Visual keys must precede the private question and query")
|
| 258 |
+
decision = len(positions)
|
| 259 |
+
count = len(candidates)
|
| 260 |
+
positions.append((row, ends[0]))
|
| 261 |
+
option_positions.extend((row, p) for p in candidates)
|
| 262 |
+
owner.extend([decision] * count)
|
| 263 |
+
slot.extend(range(count))
|
| 264 |
+
counts.append(count)
|
| 265 |
+
spec.update(counts=[count], visual_grid=shape)
|
| 266 |
+
has_visual = True
|
| 267 |
+
if training:
|
| 268 |
+
cells = target_cells(spec["target_points"], shape)
|
| 269 |
+
targets.append(cells[0])
|
| 270 |
+
weights.append(spec.get("weight", 1.0))
|
| 271 |
+
visual_owners.extend([decision] * len(cells))
|
| 272 |
+
visual_slots.extend(cells)
|
| 273 |
+
visual_mass.extend([1 / len(cells)] * len(cells))
|
| 274 |
+
continue
|
| 275 |
+
if len(ends) != len(spec["counts"]) or len(opt_ends) != sum(spec["counts"]):
|
| 276 |
+
raise ValueError(f"Decision/candidate markers mismatch: {spec['sample_id']}")
|
| 277 |
+
cursor = 0
|
| 278 |
+
for stage, (pos, count) in enumerate(zip(ends, spec["counts"])):
|
| 279 |
+
options = opt_ends[cursor : cursor + count]
|
| 280 |
+
cursor += count
|
| 281 |
+
if not all((ends[stage - 1] if stage else -1) < p < pos for p in options):
|
| 282 |
+
raise ValueError("Candidate belongs to a different decision stage")
|
| 283 |
+
if not training and stage != len(ends) - 1:
|
| 284 |
+
continue
|
| 285 |
+
owner.extend([len(positions)] * count)
|
| 286 |
+
slot.extend(range(count))
|
| 287 |
+
positions.append((row, pos))
|
| 288 |
+
option_positions.extend((row, p) for p in options)
|
| 289 |
+
counts.append(count)
|
| 290 |
+
if training:
|
| 291 |
+
targets.append(spec["targets"][stage])
|
| 292 |
+
weights.append(spec.get("weight", 1.0) / len(ends))
|
| 293 |
+
# One frozen vision forward per unique image/crop in this batch. Keep full grids
|
| 294 |
+
# for each row's native M-RoPE, and an explicit map back to each image occurrence.
|
| 295 |
+
keys = [key for s in specs for key in s["image_keys"]]
|
| 296 |
+
chunks = encoded["pixel_values"].split(encoded["image_grid_thw"].prod(-1).tolist())
|
| 297 |
+
unique, grids, pixels, image_owner = {}, [], [], []
|
| 298 |
+
for occurrence, (key, grid, pixel) in enumerate(
|
| 299 |
+
zip(keys, encoded["image_grid_thw"], chunks, strict=True)
|
| 300 |
+
):
|
| 301 |
+
signature = (key, tuple(grid.tolist()))
|
| 302 |
+
if not self.config.get("deduplicate_vision", True):
|
| 303 |
+
signature = (*signature, occurrence)
|
| 304 |
+
if signature not in unique:
|
| 305 |
+
unique[signature] = len(grids)
|
| 306 |
+
grids.append(grid)
|
| 307 |
+
pixels.append(pixel)
|
| 308 |
+
image_owner.append(unique[signature])
|
| 309 |
+
encoded["pixel_values"] = torch.cat(pixels)
|
| 310 |
+
encoded["unique_grid_thw"] = torch.stack(grids)
|
| 311 |
+
encoded["image_owner"] = torch.tensor(image_owner)
|
| 312 |
+
packed = {
|
| 313 |
+
"inputs": encoded,
|
| 314 |
+
"positions": torch.tensor(positions),
|
| 315 |
+
"option_positions": torch.tensor(option_positions),
|
| 316 |
+
"owner": torch.tensor(owner),
|
| 317 |
+
"slot": torch.tensor(slot),
|
| 318 |
+
"counts": torch.tensor(counts),
|
| 319 |
+
"targets": torch.tensor(targets),
|
| 320 |
+
"weights": torch.tensor(weights),
|
| 321 |
+
"prefix_lengths": torch.tensor(prefix_lengths),
|
| 322 |
+
"lengths": torch.tensor(lengths),
|
| 323 |
+
"state_keys": [s["state_key"] for s in specs],
|
| 324 |
+
"image_keys": list(unique),
|
| 325 |
+
"qa_count": sum(s.get("weight", 1.0) for s in specs),
|
| 326 |
+
"tokens": sum(lengths),
|
| 327 |
+
"padded_tokens": encoded["input_ids"].numel(),
|
| 328 |
+
}
|
| 329 |
+
if self.variant == KV_REPLAY and 'roi_kv_sources' in encoded:
|
| 330 |
+
packed['roi_kv_copy'] = True
|
| 331 |
+
if has_visual:
|
| 332 |
+
packed["visual_pointing"] = True
|
| 333 |
+
if training:
|
| 334 |
+
packed.update(visual_target_owner=torch.tensor(visual_owners, dtype=torch.long),
|
| 335 |
+
visual_target_slot=torch.tensor(visual_slots, dtype=torch.long),
|
| 336 |
+
visual_target_mass=torch.tensor(visual_mass, dtype=torch.float32))
|
| 337 |
+
return packed
|
| 338 |
+
|
| 339 |
+
def collate(self, rows):
|
| 340 |
+
return self.encode_specs([self.training_spec(row) for row in rows])
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
class KevSpatialModel(nn.Module):
|
| 344 |
+
def __init__(self, backbone, pointer):
|
| 345 |
+
super().__init__()
|
| 346 |
+
self.backbone, self.pointer = backbone, pointer
|
| 347 |
+
self.language_fp32 = False
|
| 348 |
+
|
| 349 |
+
@property
|
| 350 |
+
def native(self):
|
| 351 |
+
return (
|
| 352 |
+
self.backbone.get_base_model()
|
| 353 |
+
if hasattr(self.backbone, "get_base_model")
|
| 354 |
+
else self.backbone
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
def train(self, mode=True):
|
| 358 |
+
super().train(mode)
|
| 359 |
+
self.native.visual.eval()
|
| 360 |
+
return self
|
| 361 |
+
|
| 362 |
+
def language_context(self, device_type):
|
| 363 |
+
return (
|
| 364 |
+
torch.autocast(device_type=device_type, enabled=False)
|
| 365 |
+
if self.language_fp32
|
| 366 |
+
else nullcontext()
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
def embeddings(self, inputs, features=None):
|
| 370 |
+
native = self.native
|
| 371 |
+
hidden = native.get_input_embeddings()(inputs["input_ids"])
|
| 372 |
+
if features is None:
|
| 373 |
+
with torch.no_grad():
|
| 374 |
+
features = native.get_image_features(
|
| 375 |
+
inputs["pixel_values"], inputs["unique_grid_thw"], return_dict=True
|
| 376 |
+
).pooler_output
|
| 377 |
+
features = torch.cat([features[i] for i in inputs["image_owner"].tolist()]).to(hidden.dtype)
|
| 378 |
+
if 'roi_feature_sources' in inputs:
|
| 379 |
+
features = features.index_select(0, inputs['roi_feature_sources'])
|
| 380 |
+
mask, _ = native.get_placeholder_mask(inputs["input_ids"], hidden, image_features=features)
|
| 381 |
+
hidden = hidden.masked_scatter(mask, features)
|
| 382 |
+
positions, _ = native.get_rope_index(
|
| 383 |
+
inputs.get('roi_base_ids', inputs["input_ids"]),
|
| 384 |
+
inputs.get('roi_base_types', inputs["mm_token_type_ids"]),
|
| 385 |
+
image_grid_thw=inputs["image_grid_thw"],
|
| 386 |
+
attention_mask=inputs.get('roi_base_mask', inputs["attention_mask"]),
|
| 387 |
+
)
|
| 388 |
+
if 'roi_position_sources' in inputs:
|
| 389 |
+
positions = positions.gather(2, inputs['roi_position_sources'][None].expand(3, -1, -1))
|
| 390 |
+
return hidden, positions
|
| 391 |
+
|
| 392 |
+
def readout(self, hidden, packed, branch_only=False):
|
| 393 |
+
if self.pointer is None:
|
| 394 |
+
raise ValueError("Native QK requires multimodal positions; use model.forward")
|
| 395 |
+
p, o = packed["positions"], packed["option_positions"]
|
| 396 |
+
if branch_only:
|
| 397 |
+
p = p.clone()
|
| 398 |
+
o = o.clone()
|
| 399 |
+
p[:, 1] -= packed["prefix_lengths"][p[:, 0]]
|
| 400 |
+
o[:, 1] -= packed["prefix_lengths"][o[:, 0]]
|
| 401 |
+
return self.pointer(
|
| 402 |
+
hidden[p[:, 0], p[:, 1]],
|
| 403 |
+
hidden[o[:, 0], o[:, 1]],
|
| 404 |
+
packed["owner"],
|
| 405 |
+
packed["slot"],
|
| 406 |
+
packed["counts"],
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
def forward(self, packed, shared_prefix=False):
|
| 410 |
+
if packed.get("visual_pointing", False) and shared_prefix:
|
| 411 |
+
raise ValueError("Visual-token keys are in the prefix; use the full causal-row forward")
|
| 412 |
+
if self.pointer is None and shared_prefix:
|
| 413 |
+
raise ValueError("Native QK currently requires independent causal rows")
|
| 414 |
+
hidden, positions = self.embeddings(packed["inputs"])
|
| 415 |
+
with self.language_context(hidden.device.type):
|
| 416 |
+
if self.pointer is None and getattr(self.native.language_model.config, "kev_readout_layer_indices", None):
|
| 417 |
+
from .kev_multilayer_qk import multilayer_forward
|
| 418 |
+
|
| 419 |
+
return multilayer_forward(self.native.language_model, hidden, positions,
|
| 420 |
+
packed["inputs"]["attention_mask"], packed)
|
| 421 |
+
if packed.get('roi_kv_copy', False):
|
| 422 |
+
hidden = kv_replay_hidden(self.native.language_model, hidden, positions,
|
| 423 |
+
packed['inputs']['attention_mask'], packed['inputs']['roi_kv_sources'])
|
| 424 |
+
elif shared_prefix:
|
| 425 |
+
from .kev_prefix import shared_hidden
|
| 426 |
+
|
| 427 |
+
hidden = shared_hidden(self.native.language_model, hidden, positions, packed)
|
| 428 |
+
else:
|
| 429 |
+
hidden = self.native.language_model(
|
| 430 |
+
inputs_embeds=hidden,
|
| 431 |
+
position_ids=positions,
|
| 432 |
+
attention_mask=packed["inputs"]["attention_mask"],
|
| 433 |
+
use_cache=False,
|
| 434 |
+
).last_hidden_state
|
| 435 |
+
if self.pointer is None:
|
| 436 |
+
lm = self.native.language_model
|
| 437 |
+
rope_positions = positions[1:] if positions.shape[0] == 4 else positions
|
| 438 |
+
return lm.native_qk(hidden, packed, lm.rotary_emb(hidden, rope_positions))
|
| 439 |
+
return self.readout(hidden, packed, branch_only=shared_prefix)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
def build_model(config, checkpoint=None, trainable=True):
|
| 443 |
+
from transformers import Qwen3_5ForConditionalGeneration, Qwen3_5TextConfig, Qwen3_5TextModel
|
| 444 |
+
|
| 445 |
+
validate_pointing_config(config)
|
| 446 |
+
mode = train_mode(config)
|
| 447 |
+
kind = config.get("readout_kind", "added_pointer")
|
| 448 |
+
if kind not in (
|
| 449 |
+
"added_pointer",
|
| 450 |
+
"native_last_qk_mean",
|
| 451 |
+
"native_merged_qk256",
|
| 452 |
+
"native_last_qk_concat",
|
| 453 |
+
):
|
| 454 |
+
raise ValueError(f"Unsupported KEV readout: {kind}")
|
| 455 |
+
native_qk = kind != "added_pointer"
|
| 456 |
+
fusion_layers = config.get("readout_layer_indices")
|
| 457 |
+
if fusion_layers is not None and (kind != "native_last_qk_mean"
|
| 458 |
+
or config.get("layer_fusion") != "equal_logits" or config.get("gradient_checkpointing", False)
|
| 459 |
+
or config.get("point_variant", "crop_refill") != "crop_refill"):
|
| 460 |
+
raise ValueError("Multilayer ablation fixes mean heads, equal logits, old pointing and no gradient checkpointing")
|
| 461 |
+
if checkpoint:
|
| 462 |
+
saved = json.loads((Path(checkpoint) / "config.json").read_text())
|
| 463 |
+
if train_mode(saved) != mode:
|
| 464 |
+
raise ValueError("Cannot load a KEV checkpoint with a different training mode")
|
| 465 |
+
if saved.get("readout_kind", "added_pointer") != kind:
|
| 466 |
+
raise ValueError("Cannot load a KEV checkpoint with a different readout")
|
| 467 |
+
if saved.get("readout_layer_indices") != fusion_layers or saved.get("layer_fusion") != config.get("layer_fusion"):
|
| 468 |
+
raise ValueError("Cannot load a checkpoint with a different multilayer readout")
|
| 469 |
+
if saved.get("point_variant", "crop_refill") != config.get("point_variant", "crop_refill"):
|
| 470 |
+
raise ValueError("Cannot load a checkpoint with a different pointing method")
|
| 471 |
+
if native_qk and (mode != "full_language" or config.get("shared_prefix", False)):
|
| 472 |
+
raise ValueError("Native QK requires full_language and independent causal rows")
|
| 473 |
+
full = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 474 |
+
config["base_model"],
|
| 475 |
+
local_files_only=config.get("local_files_only", False),
|
| 476 |
+
dtype=torch.bfloat16,
|
| 477 |
+
attn_implementation="sdpa",
|
| 478 |
+
)
|
| 479 |
+
backbone, hidden_size = full.model, full.config.text_config.hidden_size
|
| 480 |
+
del full
|
| 481 |
+
backbone.requires_grad_(False)
|
| 482 |
+
if mode == "full_language":
|
| 483 |
+
if checkpoint:
|
| 484 |
+
directory = Path(checkpoint) / "language_model"
|
| 485 |
+
text_config = Qwen3_5TextConfig.from_pretrained(
|
| 486 |
+
directory, local_files_only=config.get("local_files_only", False)
|
| 487 |
+
)
|
| 488 |
+
text_class = Qwen3_5TextModel
|
| 489 |
+
if native_qk:
|
| 490 |
+
if getattr(text_config, "kev_readout_kind", None) != kind:
|
| 491 |
+
raise ValueError("Language checkpoint readout marker disagrees with config")
|
| 492 |
+
if (getattr(text_config, "kev_readout_layer_indices", None) != fusion_layers
|
| 493 |
+
or getattr(text_config, "kev_layer_fusion", None) != config.get("layer_fusion")):
|
| 494 |
+
raise ValueError("Language checkpoint multilayer markers disagree with config")
|
| 495 |
+
if getattr(text_config, "kev_readout_fp32", False) != config.get(
|
| 496 |
+
"native_readout_fp32", False
|
| 497 |
+
):
|
| 498 |
+
raise ValueError("Language checkpoint readout precision disagrees with config")
|
| 499 |
+
expected_layer = config.get(
|
| 500 |
+
"readout_layer_index", text_config.num_hidden_layers - 1
|
| 501 |
+
)
|
| 502 |
+
if (
|
| 503 |
+
getattr(
|
| 504 |
+
text_config, "kev_readout_layer_index", text_config.num_hidden_layers - 1
|
| 505 |
+
)
|
| 506 |
+
!= expected_layer
|
| 507 |
+
):
|
| 508 |
+
raise ValueError("Language checkpoint readout layer disagrees with config")
|
| 509 |
+
from .kev_native_qk import NativeQKTextModel
|
| 510 |
+
|
| 511 |
+
text_class = NativeQKTextModel
|
| 512 |
+
backbone.language_model, loading = text_class.from_pretrained(
|
| 513 |
+
directory,
|
| 514 |
+
config=text_config,
|
| 515 |
+
local_files_only=config.get("local_files_only", False),
|
| 516 |
+
dtype=torch.float32,
|
| 517 |
+
attn_implementation="sdpa",
|
| 518 |
+
output_loading_info=True,
|
| 519 |
+
)
|
| 520 |
+
if any(
|
| 521 |
+
loading.get(k)
|
| 522 |
+
for k in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs")
|
| 523 |
+
):
|
| 524 |
+
raise ValueError(f"Incomplete language checkpoint load: {loading}")
|
| 525 |
+
backbone.config.text_config = backbone.language_model.config
|
| 526 |
+
elif native_qk:
|
| 527 |
+
from .kev_native_qk import prune_native_qk
|
| 528 |
+
|
| 529 |
+
prune_native_qk(backbone.language_model, kind, config.get("readout_layer_index"))
|
| 530 |
+
configure_full_language(backbone, trainable=trainable)
|
| 531 |
+
if native_qk:
|
| 532 |
+
if fusion_layers is not None:
|
| 533 |
+
from .kev_multilayer_qk import configure_multilayer
|
| 534 |
+
|
| 535 |
+
configure_multilayer(backbone.language_model, fusion_layers)
|
| 536 |
+
backbone.language_model.native_qk.readout_fp32 = config.get(
|
| 537 |
+
"native_readout_fp32", False
|
| 538 |
+
)
|
| 539 |
+
backbone.language_model.config.kev_readout_fp32 = config.get(
|
| 540 |
+
"native_readout_fp32", False
|
| 541 |
+
)
|
| 542 |
+
else:
|
| 543 |
+
from peft import LoraConfig, PeftModel, get_peft_model
|
| 544 |
+
|
| 545 |
+
if checkpoint:
|
| 546 |
+
backbone = PeftModel.from_pretrained(
|
| 547 |
+
backbone, str(Path(checkpoint) / "adapter"), is_trainable=trainable
|
| 548 |
+
)
|
| 549 |
+
else:
|
| 550 |
+
backbone = get_peft_model(
|
| 551 |
+
backbone,
|
| 552 |
+
LoraConfig(
|
| 553 |
+
r=config["lora_rank"],
|
| 554 |
+
lora_alpha=config["lora_alpha"],
|
| 555 |
+
lora_dropout=config["lora_dropout"],
|
| 556 |
+
target_modules=LORA_TARGETS,
|
| 557 |
+
bias="none",
|
| 558 |
+
),
|
| 559 |
+
)
|
| 560 |
+
from .training_alignment import seed_head
|
| 561 |
+
|
| 562 |
+
pointer = (
|
| 563 |
+
None
|
| 564 |
+
if native_qk
|
| 565 |
+
else seed_head(config, lambda: PointerHead(hidden_size, config.get("pointer_dim", 256)))
|
| 566 |
+
)
|
| 567 |
+
if checkpoint and pointer is not None:
|
| 568 |
+
from safetensors.torch import load_file
|
| 569 |
+
|
| 570 |
+
pointer.load_state_dict(load_file(str(Path(checkpoint) / "pointer.safetensors")))
|
| 571 |
+
if trainable and config.get("gradient_checkpointing", False):
|
| 572 |
+
native = backbone.get_base_model() if mode == "lora" else backbone
|
| 573 |
+
native.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 574 |
+
model = KevSpatialModel(backbone, pointer)
|
| 575 |
+
inference_dtype = config.get("inference_language_dtype", "bf16")
|
| 576 |
+
if inference_dtype not in ("bf16", "fp32"):
|
| 577 |
+
raise ValueError("inference_language_dtype must be bf16 or fp32")
|
| 578 |
+
if inference_dtype == "fp32":
|
| 579 |
+
if trainable:
|
| 580 |
+
raise ValueError("FP32 language compute is an inference verification option")
|
| 581 |
+
model.native.language_model.float()
|
| 582 |
+
model.language_fp32 = True
|
| 583 |
+
if any(p.requires_grad for p in model.native.visual.parameters()):
|
| 584 |
+
raise ValueError("Vision and merger must remain frozen")
|
| 585 |
+
if trainable and mode == "full_language":
|
| 586 |
+
if not all(
|
| 587 |
+
p.requires_grad and p.dtype == torch.float32
|
| 588 |
+
for p in model.native.language_model.parameters()
|
| 589 |
+
):
|
| 590 |
+
raise ValueError("Full language training requires every language parameter in FP32")
|
| 591 |
+
if any("lora_" in n for n, _ in model.named_parameters()):
|
| 592 |
+
raise ValueError("Full language training must not contain LoRA adapters")
|
| 593 |
+
if not trainable:
|
| 594 |
+
model.requires_grad_(False).eval()
|
| 595 |
+
return model
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def save_checkpoint(model, processor, config, path, step):
|
| 599 |
+
from safetensors.torch import save_file
|
| 600 |
+
|
| 601 |
+
path = Path(path)
|
| 602 |
+
path.mkdir(parents=True, exist_ok=False)
|
| 603 |
+
mode = train_mode(config)
|
| 604 |
+
if mode == "full_language":
|
| 605 |
+
model.native.language_model.save_pretrained(
|
| 606 |
+
path / "language_model", safe_serialization=True, max_shard_size="4GB"
|
| 607 |
+
)
|
| 608 |
+
else:
|
| 609 |
+
model.backbone.save_pretrained(path / "adapter", safe_serialization=True)
|
| 610 |
+
if model.pointer is not None:
|
| 611 |
+
save_file(
|
| 612 |
+
{k: v.detach().cpu().contiguous() for k, v in model.pointer.state_dict().items()},
|
| 613 |
+
str(path / "pointer.safetensors"),
|
| 614 |
+
)
|
| 615 |
+
else:
|
| 616 |
+
metadata_path = path / "language_model/config.json"
|
| 617 |
+
metadata = json.loads(metadata_path.read_text())
|
| 618 |
+
metadata["architectures"] = ["NativeQKTextModel"]
|
| 619 |
+
metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
|
| 620 |
+
processor.save_pretrained(path / "processor")
|
| 621 |
+
(path / "config.json").write_text(json.dumps(config, indent=2) + "\n")
|
| 622 |
+
(path / "checkpoint.json").write_text(
|
| 623 |
+
json.dumps(
|
| 624 |
+
{
|
| 625 |
+
"step": step,
|
| 626 |
+
"format": "kev-spatial/v1",
|
| 627 |
+
"train_mode": mode,
|
| 628 |
+
"readout_kind": config.get("readout_kind", "added_pointer"),
|
| 629 |
+
"point_variant": config.get("point_variant", "crop_refill"),
|
| 630 |
+
"readout_layer_index": getattr(
|
| 631 |
+
model.native.language_model.config, "kev_readout_layer_index", None
|
| 632 |
+
),
|
| 633 |
+
"readout_layer_indices": config.get("readout_layer_indices"),
|
| 634 |
+
"layer_fusion": config.get("layer_fusion"),
|
| 635 |
+
"checkpoint_policy": config.get("checkpoint_policy", "full"),
|
| 636 |
+
"upstream_revision": UPSTREAM_REVISION,
|
| 637 |
+
"base_revision": config["base_revision"],
|
| 638 |
+
},
|
| 639 |
+
indent=2,
|
| 640 |
+
)
|
| 641 |
+
+ "\n"
|
| 642 |
+
)
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
def validate_checkpoint_parameters(model, config, checkpoint):
|
| 646 |
+
"""Compare saved tensors shard by shard without loading a second full model."""
|
| 647 |
+
from safetensors import safe_open
|
| 648 |
+
from safetensors.torch import load_file
|
| 649 |
+
|
| 650 |
+
checkpoint = Path(checkpoint)
|
| 651 |
+
if train_mode(config) == "full_language":
|
| 652 |
+
loaded = model.native.language_model.state_dict()
|
| 653 |
+
seen = set()
|
| 654 |
+
for shard in sorted((checkpoint / "language_model").glob("*.safetensors")):
|
| 655 |
+
with safe_open(shard, framework="pt", device="cpu") as stream:
|
| 656 |
+
for name in stream.keys(): # noqa: SIM118 -- safetensors reader is not a mapping
|
| 657 |
+
if name in seen or name not in loaded:
|
| 658 |
+
raise ValueError(f"Unexpected checkpoint parameter: {name}")
|
| 659 |
+
tensor = stream.get_tensor(name)
|
| 660 |
+
if not torch.equal(tensor, loaded[name].detach().cpu()):
|
| 661 |
+
raise ValueError(f"Language checkpoint round trip differs: {name}")
|
| 662 |
+
seen.add(name)
|
| 663 |
+
if seen != loaded.keys():
|
| 664 |
+
raise ValueError("Language checkpoint is missing state tensors")
|
| 665 |
+
else:
|
| 666 |
+
from peft import get_peft_model_state_dict
|
| 667 |
+
|
| 668 |
+
saved = load_file(str(checkpoint / "adapter/adapter_model.safetensors"))
|
| 669 |
+
loaded = get_peft_model_state_dict(model.backbone)
|
| 670 |
+
if saved.keys() != loaded.keys() or any(
|
| 671 |
+
not torch.equal(saved[k], loaded[k].detach().cpu()) for k in saved
|
| 672 |
+
):
|
| 673 |
+
raise ValueError("Adapter checkpoint round trip is not exact")
|
| 674 |
+
if model.pointer is None:
|
| 675 |
+
if (checkpoint / "pointer.safetensors").exists():
|
| 676 |
+
raise ValueError("Native QK checkpoint must not contain an added pointer")
|
| 677 |
+
return
|
| 678 |
+
head = load_file(str(checkpoint / "pointer.safetensors"))
|
| 679 |
+
loaded_head = model.pointer.state_dict()
|
| 680 |
+
if head.keys() != loaded_head.keys() or any(
|
| 681 |
+
not torch.equal(v, loaded_head[k].detach().cpu()) for k, v in head.items()
|
| 682 |
+
):
|
| 683 |
+
raise ValueError("Pointer checkpoint round trip is not exact")
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
@torch.inference_mode()
|
| 687 |
+
def predict_batch(model, builder, requests, device, runtime=None, point_decoding=None, scalar_decoding="argmax"):
|
| 688 |
+
"""Free rollout: only requests (no targets), with a private crop history per QA."""
|
| 689 |
+
visual = getattr(builder, "visual_token_pointing", False)
|
| 690 |
+
if scalar_decoding not in ("argmax", "fine_expectation"):
|
| 691 |
+
raise ValueError("Unknown scalar decoding mode")
|
| 692 |
+
if point_decoding is not None and (not visual or point_decoding not in ("argmax", "components")):
|
| 693 |
+
raise ValueError("point_decoding overrides require visual-token pointing")
|
| 694 |
+
if visual and runtime is not None:
|
| 695 |
+
raise ValueError("Visual-token pointing currently uses the uncached causal-row forward")
|
| 696 |
+
states, results = [], [None] * len(requests)
|
| 697 |
+
for index, request in enumerate(requests):
|
| 698 |
+
spec = builder.initial_spec(request["input"], request["id"])
|
| 699 |
+
spec.update(index=index, logits=[])
|
| 700 |
+
states.append(spec)
|
| 701 |
+
while states:
|
| 702 |
+
packed = to_device(builder.encode_specs(states, training=False), device)
|
| 703 |
+
logits = model(packed) if runtime is None else runtime(packed)
|
| 704 |
+
choices = logits.argmax(-1).tolist()
|
| 705 |
+
following = []
|
| 706 |
+
for state, choice, scores in zip(states, choices, logits.float().cpu()):
|
| 707 |
+
path, inp = state["path"], state["input"]
|
| 708 |
+
if visual and inp["answer_space"]["kind"] == "point":
|
| 709 |
+
values = scores[:state["counts"][-1]].tolist()
|
| 710 |
+
results[state["index"]] = {
|
| 711 |
+
**decode_visual_points(values, state["visual_grid"],
|
| 712 |
+
decoder=point_decoding or builder.config.get("point_decoder", "argmax"),
|
| 713 |
+
relative_threshold=builder.config.get("point_component_threshold", 0.1)),
|
| 714 |
+
"path": [], "logits": [values],
|
| 715 |
+
}
|
| 716 |
+
continue
|
| 717 |
+
path.append(choice)
|
| 718 |
+
state["logits"].append(scores[: state["counts"][-1]].tolist())
|
| 719 |
+
kind = inp["answer_space"]["kind"]
|
| 720 |
+
done = (
|
| 721 |
+
kind == "choice"
|
| 722 |
+
or (kind == "point" and len(path) == 3)
|
| 723 |
+
or (
|
| 724 |
+
kind == "scalar"
|
| 725 |
+
and numeric_done(path, codebook_for(inp, builder.codebook))
|
| 726 |
+
)
|
| 727 |
+
)
|
| 728 |
+
if done:
|
| 729 |
+
prediction = (
|
| 730 |
+
state["mapping"][choice]
|
| 731 |
+
if kind == "choice"
|
| 732 |
+
else decode_point(path)
|
| 733 |
+
if kind == "point"
|
| 734 |
+
else decode_numeric(path, codebook_for(inp, builder.codebook))
|
| 735 |
+
)
|
| 736 |
+
if (kind == "scalar" and inp["answer_space"].get("unit", "m") == "m"
|
| 737 |
+
and scalar_decoding == "fine_expectation"):
|
| 738 |
+
prediction = decode_scalar_expectation(
|
| 739 |
+
path[0], state["logits"][-1] if len(path) == 2 else [], builder.codebook)
|
| 740 |
+
results[state["index"]] = {
|
| 741 |
+
"prediction": prediction,
|
| 742 |
+
"path": path,
|
| 743 |
+
"logits": state["logits"],
|
| 744 |
+
}
|
| 745 |
+
else:
|
| 746 |
+
builder.advance(state, choice)
|
| 747 |
+
following.append(state)
|
| 748 |
+
states = following
|
| 749 |
+
return results
|
src/spatial_jev/kev_native_qk.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spatial decisions from the pretrained final attention's native Q/K scores.
|
| 2 |
+
|
| 3 |
+
Only existing parameters survive: the first N-1 layers and the last input norm,
|
| 4 |
+
Q-only rows, K projection and Q/K norms. No final model norm or learned pointer.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from contextlib import nullcontext
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from torch import nn
|
| 11 |
+
from transformers import Qwen3_5TextModel
|
| 12 |
+
from transformers.models.qwen3_5.modeling_qwen3_5 import rotate_half
|
| 13 |
+
|
| 14 |
+
NATIVE_QK = "native_last_qk_mean"
|
| 15 |
+
MERGED_QK = "native_merged_qk256"
|
| 16 |
+
CONCAT_QK = "native_last_qk_concat"
|
| 17 |
+
READOUT_KINDS = (NATIVE_QK, MERGED_QK, CONCAT_QK)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class NativeQKReadout(nn.Module):
|
| 21 |
+
def __init__(self, layer, merge_heads=False, concat_heads=False):
|
| 22 |
+
super().__init__()
|
| 23 |
+
if merge_heads and concat_heads:
|
| 24 |
+
raise ValueError("Weight merging and feature concatenation are distinct readouts")
|
| 25 |
+
self.concat_heads = concat_heads
|
| 26 |
+
if layer.block_type != "full_attention":
|
| 27 |
+
raise ValueError("Native QK readout requires a final full-attention layer")
|
| 28 |
+
attention = layer.self_attn
|
| 29 |
+
if attention.q_proj.bias is not None or attention.k_proj.bias is not None:
|
| 30 |
+
raise ValueError("This readout expects the Qwen3.5 bias-free projections")
|
| 31 |
+
self.head_dim = attention.head_dim
|
| 32 |
+
self.groups = attention.num_key_value_groups
|
| 33 |
+
self.heads = attention.q_proj.out_features // (2 * self.head_dim)
|
| 34 |
+
self.kv_heads = self.heads // self.groups
|
| 35 |
+
self.scale = attention.scaling
|
| 36 |
+
self.readout_fp32 = False
|
| 37 |
+
self.input_layernorm = layer.input_layernorm
|
| 38 |
+
self.k_proj = attention.k_proj
|
| 39 |
+
self.q_norm, self.k_norm = attention.q_norm, attention.k_norm
|
| 40 |
+
# Q and output gate are interleaved PER HEAD, not two global halves.
|
| 41 |
+
weight = attention.q_proj.weight
|
| 42 |
+
selected = weight.view(self.heads, 2 * self.head_dim, -1)[:, : self.head_dim]
|
| 43 |
+
if merge_heads:
|
| 44 |
+
# Average PRETRAINED WEIGHTS in FP32, before applying the shared
|
| 45 |
+
# Q/K Norms. Averaging the four GQA keys equals averaging their
|
| 46 |
+
# sixteen aligned copies. No learned fusion or random initialization.
|
| 47 |
+
selected = selected.float().mean(dim=0)
|
| 48 |
+
key_weight = attention.k_proj.weight
|
| 49 |
+
merged_key = key_weight.view(self.kv_heads, self.head_dim, -1).float().mean(dim=0)
|
| 50 |
+
self.k_proj = nn.Linear(weight.shape[1], self.head_dim, bias=False, device="meta")
|
| 51 |
+
self.k_proj.weight = nn.Parameter(
|
| 52 |
+
merged_key.detach().clone(), requires_grad=key_weight.requires_grad
|
| 53 |
+
)
|
| 54 |
+
self.heads = self.kv_heads = self.groups = 1
|
| 55 |
+
self.q_proj = nn.Linear(
|
| 56 |
+
weight.shape[1], self.heads * self.head_dim, bias=False, device="meta"
|
| 57 |
+
)
|
| 58 |
+
self.q_proj.weight = nn.Parameter(
|
| 59 |
+
selected.reshape(self.heads * self.head_dim, -1).detach().clone(),
|
| 60 |
+
requires_grad=weight.requires_grad,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
def readout_parameters(self):
|
| 64 |
+
"""Merged projections and their norms; input_layernorm stays in backbone."""
|
| 65 |
+
for module in (self.q_proj, self.k_proj, self.q_norm, self.k_norm):
|
| 66 |
+
yield from module.parameters()
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def rotate(states, indices, cos, sin):
|
| 70 |
+
# Same partial RoPE as native apply_rotary_pos_emb; gather original
|
| 71 |
+
# multimodal positions rather than renumbering selected candidates.
|
| 72 |
+
cos = cos[indices[:, 0], indices[:, 1]][:, None]
|
| 73 |
+
sin = sin[indices[:, 0], indices[:, 1]][:, None]
|
| 74 |
+
width = cos.shape[-1]
|
| 75 |
+
rotated, unchanged = states[..., :width], states[..., width:]
|
| 76 |
+
return torch.cat((rotated * cos + rotate_half(rotated) * sin, unchanged), dim=-1)
|
| 77 |
+
|
| 78 |
+
def forward(self, hidden, packed, position_embeddings):
|
| 79 |
+
context = (
|
| 80 |
+
torch.autocast(device_type=hidden.device.type, enabled=False)
|
| 81 |
+
if self.readout_fp32
|
| 82 |
+
else nullcontext()
|
| 83 |
+
)
|
| 84 |
+
with context:
|
| 85 |
+
return self._scores(hidden, packed, position_embeddings)
|
| 86 |
+
|
| 87 |
+
def _scores(self, hidden, packed, position_embeddings):
|
| 88 |
+
p, o = packed["positions"], packed["option_positions"]
|
| 89 |
+
decisions, options = hidden[p[:, 0], p[:, 1]], hidden[o[:, 0], o[:, 1]]
|
| 90 |
+
if self.readout_fp32:
|
| 91 |
+
decisions, options = decisions.float(), options.float()
|
| 92 |
+
position_embeddings = tuple(p.float() for p in position_embeddings)
|
| 93 |
+
decisions = self.input_layernorm(decisions)
|
| 94 |
+
options = self.input_layernorm(options)
|
| 95 |
+
queries = self.q_norm(self.q_proj(decisions).view(-1, self.heads, self.head_dim))
|
| 96 |
+
keys = self.k_norm(self.k_proj(options).view(-1, self.kv_heads, self.head_dim))
|
| 97 |
+
queries = self.rotate(queries, p, *position_embeddings)
|
| 98 |
+
keys = self.rotate(keys, o, *position_embeddings)
|
| 99 |
+
with torch.autocast(device_type=hidden.device.type, enabled=False):
|
| 100 |
+
keys = keys.float().repeat_interleave(self.groups, dim=1)
|
| 101 |
+
if self.concat_heads:
|
| 102 |
+
# Retain all per-head Norm/RoPE operations, then concatenate
|
| 103 |
+
# channels on BOTH sides. Repeated GQA keys stay weight-tied.
|
| 104 |
+
# For H heads this is sqrt(H) times the mean-head logit,
|
| 105 |
+
# not cross-head mixing. No additional norm or learned scale.
|
| 106 |
+
query_features = queries.float().flatten(start_dim=1)
|
| 107 |
+
key_features = keys.flatten(start_dim=1)
|
| 108 |
+
scores = (query_features[packed["owner"]] * key_features).sum(-1)
|
| 109 |
+
scores = scores * (self.heads * self.head_dim) ** -0.5
|
| 110 |
+
else:
|
| 111 |
+
scores = (queries.float()[packed["owner"]] * keys).sum(-1) * self.scale
|
| 112 |
+
# Average logits, NOT per-head probabilities. Candidate masking
|
| 113 |
+
# is independent for every QA/stage; padded slots carry no mass.
|
| 114 |
+
scores = scores.mean(dim=-1)
|
| 115 |
+
logits = scores.new_full((len(p), int(packed["counts"].max())), -torch.inf)
|
| 116 |
+
return logits.index_put((packed["owner"], packed["slot"]), scores)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def prune_native_qk(language_model, kind=NATIVE_QK, layer_index=None):
|
| 120 |
+
if kind not in READOUT_KINDS:
|
| 121 |
+
raise ValueError(f"Unsupported native readout: {kind}")
|
| 122 |
+
if hasattr(language_model, "native_qk"):
|
| 123 |
+
raise ValueError("Native QK pruning was already applied")
|
| 124 |
+
layer_index = len(language_model.layers) - 1 if layer_index is None else layer_index
|
| 125 |
+
if not isinstance(layer_index, int) or not 0 <= layer_index < len(language_model.layers):
|
| 126 |
+
raise ValueError(f"Invalid readout layer index: {layer_index}")
|
| 127 |
+
last = language_model.layers[layer_index]
|
| 128 |
+
language_model.native_qk = NativeQKReadout(
|
| 129 |
+
last, merge_heads=kind == MERGED_QK, concat_heads=kind == CONCAT_QK
|
| 130 |
+
)
|
| 131 |
+
language_model.native_qk.readout_fp32 = getattr(
|
| 132 |
+
language_model.config, "kev_readout_fp32", False
|
| 133 |
+
)
|
| 134 |
+
language_model.layers = nn.ModuleList(list(language_model.layers[:layer_index]))
|
| 135 |
+
# Execute only the original layers BEFORE the selected full attention.
|
| 136 |
+
# Later layers cannot affect this readout and must not enter the optimizer.
|
| 137 |
+
language_model.norm = nn.Identity()
|
| 138 |
+
# Keep the original layer configuration for checkpoint reconstruction.
|
| 139 |
+
language_model.config.kev_readout_kind = kind
|
| 140 |
+
language_model.config.kev_readout_layer_index = layer_index
|
| 141 |
+
return language_model
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class NativeQKTextModel(Qwen3_5TextModel):
|
| 145 |
+
"""Explicit loader for the pruned checkpoint; never load it as an ordinary LM."""
|
| 146 |
+
|
| 147 |
+
def __init__(self, config):
|
| 148 |
+
if getattr(config, "kev_readout_kind", None) not in READOUT_KINDS:
|
| 149 |
+
raise ValueError("Missing native QK checkpoint architecture marker")
|
| 150 |
+
super().__init__(config)
|
| 151 |
+
prune_native_qk(
|
| 152 |
+
self,
|
| 153 |
+
config.kev_readout_kind,
|
| 154 |
+
getattr(config, "kev_readout_layer_index", config.num_hidden_layers - 1),
|
| 155 |
+
)
|
src/spatial_jev/kev_prefix.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Functional hybrid prefixes with native multimodal embeddings and 3-axis RoPE.
|
| 2 |
+
|
| 3 |
+
Prefix and the layer-wise training schedule are adapted from
|
| 4 |
+
jaredpalmer/kev@f1535963cea021439370c23127bc970b6788e730/kev/shared_prefix.py
|
| 5 |
+
(Apache-2.0; see third_party/kev/). Branches never mutate a saved prefix.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from collections import OrderedDict
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from torch.nn import functional as F
|
| 13 |
+
from torch.utils.checkpoint import checkpoint
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Prefix:
|
| 17 |
+
record_past = True
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.keys = self.values = self.conv = None
|
| 21 |
+
self.recurrent_states = {}
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def layers(self):
|
| 25 |
+
return self
|
| 26 |
+
|
| 27 |
+
def __getitem__(self, layer_idx):
|
| 28 |
+
return self
|
| 29 |
+
|
| 30 |
+
def has_previous_state(self, layer_idx=None, state_idx=0):
|
| 31 |
+
return self.conv is not None
|
| 32 |
+
|
| 33 |
+
def update_conv_state(self, conv_input, layer_idx, conv_kernel_size, **kwargs):
|
| 34 |
+
if self.conv is not None:
|
| 35 |
+
return torch.cat([self.conv, conv_input], -1)
|
| 36 |
+
self.conv = F.pad(conv_input, (max(0, conv_kernel_size - conv_input.shape[-1]), 0))[
|
| 37 |
+
..., -conv_kernel_size:
|
| 38 |
+
]
|
| 39 |
+
return conv_input
|
| 40 |
+
|
| 41 |
+
def update_recurrent_state(self, recurrent_state, layer_idx, **kwargs):
|
| 42 |
+
self.recurrent_states.setdefault(0, recurrent_state)
|
| 43 |
+
return recurrent_state
|
| 44 |
+
|
| 45 |
+
def update(self, keys, values, layer_idx, *args, **kwargs):
|
| 46 |
+
if self.keys is None:
|
| 47 |
+
self.keys, self.values = keys, values
|
| 48 |
+
return keys, values
|
| 49 |
+
return torch.cat([self.keys, keys], -2), torch.cat([self.values, values], -2)
|
| 50 |
+
|
| 51 |
+
def branch(self, owner):
|
| 52 |
+
other = Prefix()
|
| 53 |
+
pick = lambda t: None if t is None else t.index_select(0, owner)
|
| 54 |
+
other.keys, other.values, other.conv = pick(self.keys), pick(self.values), pick(self.conv)
|
| 55 |
+
other.recurrent_states = {k: pick(v) for k, v in self.recurrent_states.items()}
|
| 56 |
+
return other
|
| 57 |
+
|
| 58 |
+
def nbytes(self):
|
| 59 |
+
tensors = [self.keys, self.values, self.conv, *self.recurrent_states.values()]
|
| 60 |
+
return sum(t.numel() * t.element_size() for t in tensors if t is not None)
|
| 61 |
+
|
| 62 |
+
def compact(self):
|
| 63 |
+
# Cached slices must own their storage so byte accounting does not hide a
|
| 64 |
+
# complete prefill tensor behind a small convolution window or KV view.
|
| 65 |
+
self.keys = None if self.keys is None else self.keys.clone().contiguous()
|
| 66 |
+
self.values = None if self.values is None else self.values.clone().contiguous()
|
| 67 |
+
self.conv = None if self.conv is None else self.conv.clone().contiguous()
|
| 68 |
+
self.recurrent_states = {
|
| 69 |
+
k: v.clone().contiguous() for k, v in self.recurrent_states.items()
|
| 70 |
+
}
|
| 71 |
+
return self
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def attention_mask(allow, hidden, implementation):
|
| 75 |
+
allow = allow[:, None]
|
| 76 |
+
if implementation == "sdpa":
|
| 77 |
+
return allow
|
| 78 |
+
return hidden.new_zeros(allow.shape).masked_fill(~allow, torch.finfo(hidden.dtype).min)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def split_rows(hidden, positions, packed):
|
| 82 |
+
prefixes = packed["prefix_lengths"].tolist()
|
| 83 |
+
lengths = packed["lengths"].tolist()
|
| 84 |
+
unique, first, owners = {}, [], []
|
| 85 |
+
for i, key in enumerate(packed["state_keys"]):
|
| 86 |
+
if key not in unique:
|
| 87 |
+
unique[key] = len(first)
|
| 88 |
+
first.append(i)
|
| 89 |
+
reference = first[unique[key]]
|
| 90 |
+
n = prefixes[i]
|
| 91 |
+
if n != prefixes[reference] or not torch.equal(
|
| 92 |
+
packed["inputs"]["input_ids"][i, :n], packed["inputs"]["input_ids"][reference, :n]
|
| 93 |
+
):
|
| 94 |
+
raise ValueError("Same state identity compiled to different prefix tokens")
|
| 95 |
+
if not torch.equal(positions[:, i, :n], positions[:, reference, :n]):
|
| 96 |
+
raise ValueError("Same state identity compiled to different multimodal positions")
|
| 97 |
+
owners.append(unique[key])
|
| 98 |
+
ns, nb = max(prefixes), max(n - p for n, p in zip(lengths, prefixes))
|
| 99 |
+
s = hidden.new_zeros((len(first), ns, hidden.shape[-1]))
|
| 100 |
+
b = hidden.new_zeros((len(lengths), nb, hidden.shape[-1]))
|
| 101 |
+
sp = positions.new_zeros((3, len(first), ns))
|
| 102 |
+
bp = positions.new_zeros((3, len(lengths), nb))
|
| 103 |
+
sm = torch.zeros(s.shape[:2], dtype=torch.bool, device=hidden.device)
|
| 104 |
+
bm = torch.zeros(b.shape[:2], dtype=torch.bool, device=hidden.device)
|
| 105 |
+
for j, i in enumerate(first):
|
| 106 |
+
n = prefixes[i]
|
| 107 |
+
s[j, -n:], sp[:, j, -n:], sm[j, -n:] = hidden[i, :n], positions[:, i, :n], True
|
| 108 |
+
for i, (p, n) in enumerate(zip(prefixes, lengths)):
|
| 109 |
+
b[i, : n - p], bp[:, i, : n - p], bm[i, : n - p] = (
|
| 110 |
+
hidden[i, p:n],
|
| 111 |
+
positions[:, i, p:n],
|
| 112 |
+
True,
|
| 113 |
+
)
|
| 114 |
+
return s, b, sp, bp, sm, bm, torch.tensor(owners, device=hidden.device)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def masks(lm, s, b, sm, bm, owner):
|
| 118 |
+
ns, nb, device = s.shape[1], b.shape[1], s.device
|
| 119 |
+
causal = lambda n: torch.ones(n, n, dtype=torch.bool, device=device).tril()
|
| 120 |
+
eye = lambda n: torch.eye(n, dtype=torch.bool, device=device)
|
| 121 |
+
impl = lm.config._attn_implementation
|
| 122 |
+
s_allow = (causal(ns)[None] & sm[:, None, :]) | eye(ns)[None]
|
| 123 |
+
b_allow = torch.cat(
|
| 124 |
+
[
|
| 125 |
+
sm[owner][:, None, :].expand(-1, nb, -1),
|
| 126 |
+
(causal(nb)[None] & bm[:, None, :]) | eye(nb)[None],
|
| 127 |
+
],
|
| 128 |
+
-1,
|
| 129 |
+
)
|
| 130 |
+
ms = {
|
| 131 |
+
"full_attention": None
|
| 132 |
+
if impl == "sdpa" and bool(sm.all())
|
| 133 |
+
else attention_mask(s_allow, s, impl),
|
| 134 |
+
"linear_attention": sm.to(s.dtype),
|
| 135 |
+
}
|
| 136 |
+
mb = {"full_attention": attention_mask(b_allow, b, impl), "linear_attention": bm.to(b.dtype)}
|
| 137 |
+
return ms, mb
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def shared_hidden(lm, hidden, positions, packed):
|
| 141 |
+
s, b, sp, bp, sm, bm, owner = split_rows(hidden, positions, packed)
|
| 142 |
+
ms, mb = masks(lm, s, b, sm, bm, owner)
|
| 143 |
+
rs, rb = lm.rotary_emb(s, sp), lm.rotary_emb(b, bp)
|
| 144 |
+
for layer in lm.layers:
|
| 145 |
+
# Capture the current layer, never a late-bound loop variable in recomputation.
|
| 146 |
+
def step(s, b, layer=layer):
|
| 147 |
+
prefix = Prefix()
|
| 148 |
+
s = layer.forward(
|
| 149 |
+
s,
|
| 150 |
+
position_embeddings=rs,
|
| 151 |
+
attention_mask=ms[layer.block_type],
|
| 152 |
+
past_key_values=prefix,
|
| 153 |
+
)
|
| 154 |
+
b = layer.forward(
|
| 155 |
+
b,
|
| 156 |
+
position_embeddings=rb,
|
| 157 |
+
attention_mask=mb[layer.block_type],
|
| 158 |
+
past_key_values=prefix.branch(owner),
|
| 159 |
+
)
|
| 160 |
+
return s * sm[..., None], b
|
| 161 |
+
|
| 162 |
+
if lm.gradient_checkpointing and lm.training and torch.is_grad_enabled():
|
| 163 |
+
s, b = checkpoint(step, s, b, use_reentrant=False)
|
| 164 |
+
else:
|
| 165 |
+
s, b = step(s, b)
|
| 166 |
+
return lm.norm(b)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class ByteLRU:
|
| 170 |
+
def __init__(self, max_bytes):
|
| 171 |
+
self.max_bytes, self.bytes = max_bytes, 0
|
| 172 |
+
self.entries = OrderedDict()
|
| 173 |
+
self.hits = self.misses = 0
|
| 174 |
+
|
| 175 |
+
def get(self, key):
|
| 176 |
+
if key not in self.entries:
|
| 177 |
+
self.misses += 1
|
| 178 |
+
return None
|
| 179 |
+
self.hits += 1
|
| 180 |
+
self.entries.move_to_end(key)
|
| 181 |
+
return self.entries[key][0]
|
| 182 |
+
|
| 183 |
+
def put(self, key, value, size):
|
| 184 |
+
if size > self.max_bytes:
|
| 185 |
+
return
|
| 186 |
+
if key in self.entries:
|
| 187 |
+
self.bytes -= self.entries.pop(key)[1]
|
| 188 |
+
while self.entries and self.bytes + size > self.max_bytes:
|
| 189 |
+
self.bytes -= self.entries.popitem(last=False)[1][1]
|
| 190 |
+
self.entries[key] = (value, size)
|
| 191 |
+
self.bytes += size
|
| 192 |
+
|
| 193 |
+
def clear(self):
|
| 194 |
+
self.entries.clear()
|
| 195 |
+
self.bytes = 0
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class KevRuntime:
|
| 199 |
+
"""An immutable-model session with byte-bounded image and hybrid prefix caches.
|
| 200 |
+
|
| 201 |
+
Every round recomputes only the private question history from the original state.
|
| 202 |
+
All branches of one state are batched; attention/conv/recurrent prefixes are read-only.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
def __init__(self, model, config, prefix_bytes=8 * 1024**3, image_bytes=2 * 1024**3,
|
| 206 |
+
enable_native_qk=False):
|
| 207 |
+
from .roi_replay import REPLAY_VARIANTS
|
| 208 |
+
if config.get('point_variant') in REPLAY_VARIANTS:
|
| 209 |
+
raise ValueError('ROI replay uses uncached full rows, not the persistent hybrid-prefix runtime')
|
| 210 |
+
if config.get("point_variant") == "visual_tokens":
|
| 211 |
+
raise ValueError("Visual-token pointing requires prefix keys; cached branch-only readout is unsupported")
|
| 212 |
+
if not enable_native_qk and config.get("readout_kind") in (
|
| 213 |
+
"native_last_qk_mean",
|
| 214 |
+
"native_merged_qk256",
|
| 215 |
+
"native_last_qk_concat",
|
| 216 |
+
):
|
| 217 |
+
raise ValueError("Native QK currently uses the uncached causal-row forward")
|
| 218 |
+
if enable_native_qk and (config.get("readout_kind") != "native_last_qk_mean"
|
| 219 |
+
or config.get("readout_layer_indices") is not None
|
| 220 |
+
or config.get("gradient_checkpointing", False)):
|
| 221 |
+
raise ValueError("Experimental native cache requires single-layer mean and no checkpointing")
|
| 222 |
+
import hashlib
|
| 223 |
+
import json
|
| 224 |
+
|
| 225 |
+
self.model = model
|
| 226 |
+
self.signature = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
|
| 227 |
+
self.versions = tuple((p._version, p.dtype, p.device) for p in model.parameters())
|
| 228 |
+
self.prefix_cache, self.image_cache = ByteLRU(prefix_bytes), ByteLRU(image_bytes)
|
| 229 |
+
|
| 230 |
+
def clear(self):
|
| 231 |
+
self.prefix_cache.clear()
|
| 232 |
+
self.image_cache.clear()
|
| 233 |
+
|
| 234 |
+
@torch.inference_mode()
|
| 235 |
+
def __call__(self, packed):
|
| 236 |
+
if packed.get("visual_pointing", False):
|
| 237 |
+
raise ValueError("Visual-token pointing requires the uncached causal-row forward")
|
| 238 |
+
if self.model.training or self.versions != tuple(
|
| 239 |
+
(p._version, p.dtype, p.device) for p in self.model.parameters()
|
| 240 |
+
):
|
| 241 |
+
raise ValueError("Create a fresh runtime after changing model weights or training mode")
|
| 242 |
+
model, inputs = self.model, packed["inputs"]
|
| 243 |
+
device_type = inputs["input_ids"].device.type
|
| 244 |
+
precision = (
|
| 245 |
+
torch.is_autocast_enabled(device_type),
|
| 246 |
+
str(torch.get_autocast_dtype(device_type)),
|
| 247 |
+
torch.get_float32_matmul_precision(),
|
| 248 |
+
os.environ.get("TRITON_F32_DEFAULT", "tf32"),
|
| 249 |
+
)
|
| 250 |
+
scope = (self.signature, precision)
|
| 251 |
+
features = [self.image_cache.get((scope, k)) for k in packed["image_keys"]]
|
| 252 |
+
missing = [i for i, f in enumerate(features) if f is None]
|
| 253 |
+
if missing:
|
| 254 |
+
chunks = inputs["pixel_values"].split(inputs["unique_grid_thw"].prod(-1).tolist())
|
| 255 |
+
fs = model.native.get_image_features(
|
| 256 |
+
torch.cat([chunks[i] for i in missing]),
|
| 257 |
+
inputs["unique_grid_thw"][missing],
|
| 258 |
+
return_dict=True,
|
| 259 |
+
).pooler_output
|
| 260 |
+
for i, f in zip(missing, fs):
|
| 261 |
+
f = f.clone().contiguous()
|
| 262 |
+
features[i] = f
|
| 263 |
+
self.image_cache.put(
|
| 264 |
+
(scope, packed["image_keys"][i]), f, f.numel() * f.element_size()
|
| 265 |
+
)
|
| 266 |
+
hidden, positions = model.embeddings(inputs, features=features)
|
| 267 |
+
with model.language_context(hidden.device.type):
|
| 268 |
+
return self.language_forward(packed, hidden, positions, scope)
|
| 269 |
+
|
| 270 |
+
def language_forward(self, packed, hidden, positions, scope):
|
| 271 |
+
model, inputs = self.model, packed["inputs"]
|
| 272 |
+
s, b, sp, bp, sm, bm, owner = split_rows(hidden, positions, packed)
|
| 273 |
+
lm = model.native.language_model
|
| 274 |
+
result = torch.zeros_like(b)
|
| 275 |
+
for state in range(len(s)):
|
| 276 |
+
rows = (owner == state).nonzero().flatten()
|
| 277 |
+
first = int(rows[0])
|
| 278 |
+
ns = int(packed["prefix_lengths"][first])
|
| 279 |
+
key = (
|
| 280 |
+
scope,
|
| 281 |
+
packed["state_keys"][first],
|
| 282 |
+
str(hidden.dtype),
|
| 283 |
+
tuple(inputs["input_ids"][first, :ns].tolist()),
|
| 284 |
+
tuple(positions[:, first, :ns].flatten().tolist()),
|
| 285 |
+
)
|
| 286 |
+
entry = self.prefix_cache.get(key)
|
| 287 |
+
if entry is None:
|
| 288 |
+
hs = s[state : state + 1, -ns:]
|
| 289 |
+
rs = lm.rotary_emb(hs, sp[:, state : state + 1, -ns:])
|
| 290 |
+
entry = []
|
| 291 |
+
for layer in lm.layers:
|
| 292 |
+
prefix = Prefix()
|
| 293 |
+
hs = layer.forward(hs, position_embeddings=rs, past_key_values=prefix)
|
| 294 |
+
entry.append(prefix.compact())
|
| 295 |
+
self.prefix_cache.put(key, entry, sum(p.nbytes() for p in entry))
|
| 296 |
+
# Different states need not inherit the longest unrelated question's
|
| 297 |
+
# padding. Their final readouts only index these real branch lengths.
|
| 298 |
+
branch_length = int(bm[rows].sum(-1).max())
|
| 299 |
+
hb, mask = b[rows, :branch_length], bm[rows, :branch_length]
|
| 300 |
+
rb = lm.rotary_emb(hb, bp[:, rows, :branch_length])
|
| 301 |
+
owners = torch.zeros(len(rows), dtype=torch.long, device=hb.device)
|
| 302 |
+
_, mb = masks(
|
| 303 |
+
lm, s[state : state + 1, -ns:], hb, sm[state : state + 1, -ns:], mask, owners
|
| 304 |
+
)
|
| 305 |
+
for layer, prefix in zip(lm.layers, entry):
|
| 306 |
+
hb = layer.forward(
|
| 307 |
+
hb,
|
| 308 |
+
position_embeddings=rb,
|
| 309 |
+
attention_mask=mb[layer.block_type],
|
| 310 |
+
past_key_values=prefix.branch(owners),
|
| 311 |
+
)
|
| 312 |
+
result[rows, :branch_length] = lm.norm(hb)
|
| 313 |
+
if model.pointer is None:
|
| 314 |
+
# The cached image prefix is unchanged. Candidates and decisions are
|
| 315 |
+
# private text suffix tokens; retain their ORIGINAL M-RoPE coordinates
|
| 316 |
+
# while translating tensor indices into the returned suffix buffer.
|
| 317 |
+
selected = dict(packed)
|
| 318 |
+
for key in ("positions", "option_positions"):
|
| 319 |
+
indices = packed[key].clone()
|
| 320 |
+
indices[:, 1] -= packed["prefix_lengths"][indices[:, 0]]
|
| 321 |
+
if (indices[:, 1] < 0).any():
|
| 322 |
+
raise ValueError("Native suffix cache cannot read keys inside the prefix")
|
| 323 |
+
selected[key] = indices
|
| 324 |
+
return lm.native_qk(result, selected, lm.rotary_emb(result, bp))
|
| 325 |
+
return model.readout(result, packed, branch_only=True)
|
src/spatial_jev/numeric_tasks.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Input-selected numeric codebooks; never interpret counts or degrees as meters."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
COUNT_CODEBOOK = {"version": "integer-32x32-v1", "unit": "count", "fine_bins": 32,
|
| 6 |
+
"maximum": 1023}
|
| 7 |
+
ANGLE_CODEBOOK = {"version": "degrees-30x32-v1", "unit": "deg", "fine_bins": 32,
|
| 8 |
+
"edges_m": list(range(0, 361, 30)), "overflow_representative_m": 360.0}
|
| 9 |
+
# edges_m is the historical controller's internal key, not the physical unit.
|
| 10 |
+
# Public answer spaces, prompts, validators and decoded units remain explicit.
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def codebook_for(inp, metric):
|
| 14 |
+
unit = inp["answer_space"].get("unit", "m")
|
| 15 |
+
if unit == "m":
|
| 16 |
+
return metric
|
| 17 |
+
if unit == "count":
|
| 18 |
+
return COUNT_CODEBOOK
|
| 19 |
+
if unit == "deg":
|
| 20 |
+
return ANGLE_CODEBOOK
|
| 21 |
+
raise ValueError(f"Unsupported numeric unit: {unit}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def numeric_path(value, codebook):
|
| 25 |
+
from .hierarchy import scalar_path
|
| 26 |
+
if codebook.get("unit", "m") != "count":
|
| 27 |
+
if codebook.get("unit") == "deg" and not 0 <= value <= 360:
|
| 28 |
+
raise ValueError("Angle outside [0, 360]")
|
| 29 |
+
return scalar_path(value, codebook)
|
| 30 |
+
if isinstance(value, bool) or not math.isfinite(value) or int(value) != value or not 0 <= value <= 1023:
|
| 31 |
+
raise ValueError("Count outside integer codebook [0, 1023]")
|
| 32 |
+
return [int(value) // 32, int(value) % 32]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def numeric_done(path, codebook):
|
| 36 |
+
return len(path) == 2 or (codebook.get("unit", "m") != "count" and path[0] in (0, len(codebook["edges_m"])))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def decode_numeric(path, codebook):
|
| 40 |
+
from .hierarchy import decode_scalar
|
| 41 |
+
if codebook.get("unit", "m") != "count":
|
| 42 |
+
return decode_scalar(path, codebook)
|
| 43 |
+
if len(path) != 2 or any(not 0 <= n < 32 for n in path):
|
| 44 |
+
raise ValueError("Invalid integer decision path")
|
| 45 |
+
return 32 * path[0] + path[1]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def numeric_prompt(inp, root, codebook):
|
| 49 |
+
"""Non-metric prompts. Metric prompts intentionally keep their old exact bytes."""
|
| 50 |
+
if codebook["unit"] == "count":
|
| 51 |
+
options = ([f"count {32*i} through {32*i+31} (inclusive)" for i in range(32)]
|
| 52 |
+
if root is None else [f"count exactly {32*root+i}" for i in range(32)])
|
| 53 |
+
instruction = "Answer the counting question. Select the integer count or inclusive count range."
|
| 54 |
+
else:
|
| 55 |
+
from .hierarchy import scalar_interval
|
| 56 |
+
edges = codebook["edges_m"]
|
| 57 |
+
if root is None:
|
| 58 |
+
options = ["exactly zero degrees"]
|
| 59 |
+
options += [f"{lo:g} to {hi:g} degrees" for lo, hi in zip(edges, edges[1:])]
|
| 60 |
+
options[1] = "greater than zero and less than 30 degrees"
|
| 61 |
+
options += ["greater than 360 degrees (out of supported range)"]
|
| 62 |
+
else:
|
| 63 |
+
lo, hi = scalar_interval(root, codebook)
|
| 64 |
+
step = (hi - lo) / codebook["fine_bins"]
|
| 65 |
+
options = [f"{lo+i*step:.6g} to {lo+(i+1)*step:.6g} degrees" for i in range(32)]
|
| 66 |
+
instruction = ("Estimate the angle in degrees, preserving the reference frame and rotation "
|
| 67 |
+
"direction specified in the question. Ranges include the lower endpoint and "
|
| 68 |
+
"exclude the upper, except that 360 is included. Exact zero has its own class.")
|
| 69 |
+
return (f"Question: {inp['question']}\n{instruction}\nOptions:\n"
|
| 70 |
+
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(options))
|
| 71 |
+
+ "\nSelect exactly one option."), len(options)
|
src/spatial_jev/point_probability_map.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Nine-way conditional trees and connected-component multi-point readout."""
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from .hierarchy import POINT_RC
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def softmax9(logits):
|
| 9 |
+
values = np.asarray(logits, dtype=np.float64)
|
| 10 |
+
if values.shape != (9,) or not np.isfinite(values).all():
|
| 11 |
+
raise ValueError("Expected nine finite logits")
|
| 12 |
+
probabilities = np.exp(values - values.max())
|
| 13 |
+
return probabilities / probabilities.sum()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def grid_location(path):
|
| 17 |
+
row = col = 0
|
| 18 |
+
for choice in path:
|
| 19 |
+
dr, dc = POINT_RC[choice]
|
| 20 |
+
row, col = row * 3 + dr, col * 3 + dc
|
| 21 |
+
return row, col
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def branch_spec(builder, parent, choice):
|
| 25 |
+
# Copy mutable histories, but share immutable PIL images and input metadata.
|
| 26 |
+
child = dict(parent)
|
| 27 |
+
for key in ("images", "image_keys", "counts", "mapping", "path"):
|
| 28 |
+
child[key] = list(parent[key])
|
| 29 |
+
if 'roi_boxes' in parent:
|
| 30 |
+
child['roi_boxes'] = list(parent['roi_boxes'])
|
| 31 |
+
child["path"].append(choice)
|
| 32 |
+
builder.advance(child, choice)
|
| 33 |
+
return child
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def probability_map(builder, request, evaluate_nodes, mode="full", root_relative_cutoff=0.01):
|
| 37 |
+
"""Three levels, 729 leaves; labels/masks/counts are not accepted as arguments.
|
| 38 |
+
|
| 39 |
+
root_pruned uses only the first decision to omit low-mass root subtrees.
|
| 40 |
+
Unvisited leaves remain explicitly unknown, with their missing mass reported.
|
| 41 |
+
No probability renormalization is applied after pruning.
|
| 42 |
+
"""
|
| 43 |
+
if mode not in ("full", "root_pruned"):
|
| 44 |
+
raise ValueError("Unknown tree policy")
|
| 45 |
+
if not 0 <= root_relative_cutoff <= 1:
|
| 46 |
+
raise ValueError("Invalid root cutoff")
|
| 47 |
+
if request["input"]["answer_space"]["kind"] != "point":
|
| 48 |
+
raise ValueError("Point input required")
|
| 49 |
+
grid = np.zeros((27, 27), dtype=np.float64)
|
| 50 |
+
known = np.zeros((27, 27), dtype=bool)
|
| 51 |
+
nodes, pruned = [], []
|
| 52 |
+
frontier = [(builder.initial_spec(request["input"], request["id"]), 1.0)]
|
| 53 |
+
for depth in range(3):
|
| 54 |
+
logits = evaluate_nodes([state for state, _ in frontier])
|
| 55 |
+
if len(logits) != len(frontier):
|
| 56 |
+
raise ValueError("Node evaluator returned a different batch size")
|
| 57 |
+
following = []
|
| 58 |
+
for (state, mass), scores in zip(frontier, logits):
|
| 59 |
+
probabilities = softmax9(scores)
|
| 60 |
+
path = list(state["path"])
|
| 61 |
+
nodes.append({"path": path, "mass": mass, "logits": list(map(float, scores)),
|
| 62 |
+
"probabilities": probabilities.tolist()})
|
| 63 |
+
cutoff = probabilities.max() * root_relative_cutoff
|
| 64 |
+
for choice, probability in enumerate(probabilities):
|
| 65 |
+
child_path = [*path, choice]
|
| 66 |
+
child_mass = mass * float(probability)
|
| 67 |
+
if depth == 2:
|
| 68 |
+
row, col = grid_location(child_path)
|
| 69 |
+
if known[row, col]:
|
| 70 |
+
raise ValueError("Duplicate leaf location")
|
| 71 |
+
grid[row, col], known[row, col] = child_mass, True
|
| 72 |
+
elif depth == 0 and mode == "root_pruned" and probability < cutoff:
|
| 73 |
+
pruned.append({"path": child_path, "mass": child_mass})
|
| 74 |
+
else:
|
| 75 |
+
following.append((branch_spec(builder, state, choice), child_mass))
|
| 76 |
+
frontier = following
|
| 77 |
+
retained, omitted = float(grid.sum()), sum(r["mass"] for r in pruned)
|
| 78 |
+
if not np.isclose(retained + omitted, 1, atol=1e-12, rtol=0):
|
| 79 |
+
raise ValueError("Probability mass was lost or duplicated")
|
| 80 |
+
return {"probabilities": grid, "known": known, "nodes": nodes,
|
| 81 |
+
"evaluated_nodes": len(nodes), "evaluated_leaves": int(known.sum()),
|
| 82 |
+
"retained_mass": retained, "pruned_mass": omitted, "pruned_branches": pruned}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def component_points(probabilities, known=None, relative_threshold=0.1, connectivity=8):
|
| 86 |
+
"""Pick each superlevel component's maximum cell, with deterministic tie breaks."""
|
| 87 |
+
grid = np.asarray(probabilities, dtype=np.float64)
|
| 88 |
+
if grid.ndim != 2 or not np.isfinite(grid).all() or (grid < 0).any():
|
| 89 |
+
raise ValueError("Invalid spatial probability grid")
|
| 90 |
+
if not 0 < relative_threshold <= 1 or connectivity not in (4, 8):
|
| 91 |
+
raise ValueError("Invalid connected-component settings")
|
| 92 |
+
known = np.ones_like(grid, dtype=bool) if known is None else np.asarray(known, dtype=bool)
|
| 93 |
+
if known.shape != grid.shape:
|
| 94 |
+
raise ValueError("Known-cell mask shape mismatch")
|
| 95 |
+
peak = float(grid[known].max()) if known.any() else 0.0
|
| 96 |
+
threshold = peak * relative_threshold
|
| 97 |
+
mask = known & (grid >= threshold) & (grid > 0)
|
| 98 |
+
visited = np.zeros_like(mask)
|
| 99 |
+
neighbors = [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
| 100 |
+
if connectivity == 8:
|
| 101 |
+
neighbors += [(-1, -1), (-1, 1), (1, -1), (1, 1)]
|
| 102 |
+
height, width = grid.shape
|
| 103 |
+
components, points = [], []
|
| 104 |
+
for row, col in zip(*np.where(mask)):
|
| 105 |
+
if visited[row, col]:
|
| 106 |
+
continue
|
| 107 |
+
visited[row, col] = True
|
| 108 |
+
stack, cells = [(int(row), int(col))], []
|
| 109 |
+
while stack:
|
| 110 |
+
y, x = stack.pop()
|
| 111 |
+
cells.append((y, x))
|
| 112 |
+
for dy, dx in neighbors:
|
| 113 |
+
yy, xx = y + dy, x + dx
|
| 114 |
+
if 0 <= yy < height and 0 <= xx < width and mask[yy, xx] and not visited[yy, xx]:
|
| 115 |
+
visited[yy, xx] = True
|
| 116 |
+
stack.append((yy, xx))
|
| 117 |
+
# Prefer the highest probability, then topmost/leftmost on exact ties.
|
| 118 |
+
y, x = min(cells, key=lambda cell: (-grid[cell], cell[0], cell[1]))
|
| 119 |
+
point = [(x + 0.5) / width, (y + 0.5) / height]
|
| 120 |
+
points.append(point)
|
| 121 |
+
components.append({"cells": [list(cell) for cell in cells], "peak_cell": [y, x],
|
| 122 |
+
"peak_probability": float(grid[y, x]),
|
| 123 |
+
"mass": sum(float(grid[cell]) for cell in cells)})
|
| 124 |
+
return {"points": points, "components": components, "threshold": threshold,
|
| 125 |
+
"relative_threshold": relative_threshold, "connectivity": connectivity}
|
src/spatial_jev/qwen35.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qwen3.5 multimodal backbone with the existing spatial decision contract.
|
| 2 |
+
|
| 3 |
+
Each QA is one causal row, with crops revealed at their actual decision stage.
|
| 4 |
+
This baseline deliberately uses complete-prefix rollout before cache optimization.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
from torch import nn
|
| 13 |
+
|
| 14 |
+
from .hierarchy import ROOT_BOX, child_box, decode_point, decode_scalar, point_path, scalar_path
|
| 15 |
+
from .unified import SharedClassifier, UnifiedBuilder
|
| 16 |
+
|
| 17 |
+
LORA_TARGETS = (
|
| 18 |
+
r"language_model\.layers\.\d+\.(?:"
|
| 19 |
+
r"self_attn\.(?:q_proj|k_proj|v_proj|o_proj)|"
|
| 20 |
+
r"linear_attn\.(?:in_proj_qkv|in_proj_z|in_proj_a|in_proj_b|out_proj)|"
|
| 21 |
+
r"mlp\.(?:gate_proj|up_proj|down_proj))"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def sample_seed(sample_id, seed):
|
| 26 |
+
return seed ^ int(hashlib.sha256(sample_id.encode()).hexdigest()[:8], 16)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def train_mode(config):
|
| 30 |
+
mode = config.get("train_mode", "lora")
|
| 31 |
+
if mode not in ("lora", "full_language"):
|
| 32 |
+
raise ValueError(f"Unsupported train mode: {mode}")
|
| 33 |
+
return mode
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def configure_full_language(backbone, trainable=True):
|
| 37 |
+
"""Keep the original vision/merger frozen; FP32 master weights for full updates."""
|
| 38 |
+
backbone.requires_grad_(False)
|
| 39 |
+
backbone.language_model.float().requires_grad_(trainable)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def make_processor(config):
|
| 43 |
+
from transformers import AutoProcessor
|
| 44 |
+
|
| 45 |
+
processor = AutoProcessor.from_pretrained(
|
| 46 |
+
config["base_model"], local_files_only=config.get("local_files_only", False)
|
| 47 |
+
)
|
| 48 |
+
processor.tokenizer.padding_side = "right"
|
| 49 |
+
processor.image_processor.size = {
|
| 50 |
+
"shortest_edge": config["min_image_tokens"] * 32 * 32,
|
| 51 |
+
"longest_edge": config["max_image_tokens"] * 32 * 32,
|
| 52 |
+
}
|
| 53 |
+
return processor
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class Qwen35SpatialModel(nn.Module):
|
| 57 |
+
def __init__(self, backbone, classifier):
|
| 58 |
+
super().__init__()
|
| 59 |
+
self.backbone, self.classifier = backbone, classifier
|
| 60 |
+
|
| 61 |
+
def forward(self, inputs, positions, counts):
|
| 62 |
+
out = self.backbone(**inputs, use_cache=False)
|
| 63 |
+
hidden = out.last_hidden_state[positions[:, 0], positions[:, 1]]
|
| 64 |
+
return self.classifier(hidden, counts)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def build_model(config, checkpoint=None, trainable=True):
|
| 68 |
+
from transformers import Qwen3_5ForConditionalGeneration, Qwen3_5TextConfig, Qwen3_5TextModel
|
| 69 |
+
|
| 70 |
+
mode = train_mode(config)
|
| 71 |
+
full = Qwen3_5ForConditionalGeneration.from_pretrained(
|
| 72 |
+
config["base_model"],
|
| 73 |
+
local_files_only=True,
|
| 74 |
+
dtype=torch.bfloat16,
|
| 75 |
+
attn_implementation="sdpa",
|
| 76 |
+
)
|
| 77 |
+
backbone, hidden = full.model, full.config.text_config.hidden_size
|
| 78 |
+
del full
|
| 79 |
+
backbone.requires_grad_(False)
|
| 80 |
+
if mode == "full_language":
|
| 81 |
+
if checkpoint:
|
| 82 |
+
text_directory = Path(checkpoint) / "language_model"
|
| 83 |
+
text_config = Qwen3_5TextConfig.from_pretrained(text_directory, local_files_only=True)
|
| 84 |
+
backbone.language_model = Qwen3_5TextModel.from_pretrained(
|
| 85 |
+
text_directory,
|
| 86 |
+
config=text_config,
|
| 87 |
+
local_files_only=True,
|
| 88 |
+
dtype=torch.float32,
|
| 89 |
+
attn_implementation="sdpa",
|
| 90 |
+
)
|
| 91 |
+
backbone.config.text_config = backbone.language_model.config
|
| 92 |
+
configure_full_language(backbone, trainable)
|
| 93 |
+
else:
|
| 94 |
+
from peft import LoraConfig, PeftModel, get_peft_model
|
| 95 |
+
|
| 96 |
+
if checkpoint:
|
| 97 |
+
backbone = PeftModel.from_pretrained(
|
| 98 |
+
backbone, str(Path(checkpoint) / "adapter"), is_trainable=trainable
|
| 99 |
+
)
|
| 100 |
+
else:
|
| 101 |
+
backbone = get_peft_model(
|
| 102 |
+
backbone,
|
| 103 |
+
LoraConfig(
|
| 104 |
+
r=config["lora_rank"],
|
| 105 |
+
lora_alpha=config["lora_alpha"],
|
| 106 |
+
lora_dropout=config["lora_dropout"],
|
| 107 |
+
target_modules=LORA_TARGETS,
|
| 108 |
+
bias="none",
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
from .training_alignment import seed_head
|
| 112 |
+
|
| 113 |
+
classifier = seed_head(config, lambda: SharedClassifier(hidden, config["max_choices"]))
|
| 114 |
+
if checkpoint:
|
| 115 |
+
from safetensors.torch import load_file
|
| 116 |
+
|
| 117 |
+
classifier.load_state_dict(load_file(str(Path(checkpoint) / "classifier.safetensors")))
|
| 118 |
+
if trainable and config.get("gradient_checkpointing", False):
|
| 119 |
+
native = backbone.get_base_model() if mode == "lora" else backbone
|
| 120 |
+
native.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 121 |
+
model = Qwen35SpatialModel(backbone, classifier)
|
| 122 |
+
if any(
|
| 123 |
+
p.requires_grad
|
| 124 |
+
for n, p in backbone.named_parameters()
|
| 125 |
+
if n.startswith("visual.") or ".visual." in n
|
| 126 |
+
):
|
| 127 |
+
raise ValueError("Vision encoder and merger must remain frozen")
|
| 128 |
+
if trainable and not any(
|
| 129 |
+
"linear_attn" in n and p.requires_grad for n, p in backbone.named_parameters()
|
| 130 |
+
):
|
| 131 |
+
raise ValueError("DeltaNet must have trainable parameters")
|
| 132 |
+
if not trainable:
|
| 133 |
+
model.requires_grad_(False).eval()
|
| 134 |
+
return model
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def move_inputs(inputs, device):
|
| 138 |
+
return {
|
| 139 |
+
key: value.to(
|
| 140 |
+
device,
|
| 141 |
+
dtype=torch.bfloat16 if value.is_floating_point() else value.dtype,
|
| 142 |
+
non_blocking=True,
|
| 143 |
+
)
|
| 144 |
+
for key, value in inputs.items()
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class Qwen35Builder(UnifiedBuilder):
|
| 149 |
+
def __init__(self, processor, config):
|
| 150 |
+
self.processor, self.config = processor, config
|
| 151 |
+
self.codebook, self.variant = config["scalar_codebook"], "crop_refill"
|
| 152 |
+
rendered = self.render("Example question", 0)
|
| 153 |
+
suffix = rendered[rendered.rindex("<|im_start|>assistant") :]
|
| 154 |
+
self.header = processor.tokenizer.encode(suffix, add_special_tokens=False)
|
| 155 |
+
|
| 156 |
+
def render(self, text, image_count):
|
| 157 |
+
content = [{"type": "image"} for _ in range(image_count)]
|
| 158 |
+
content.append({"type": "text", "text": text})
|
| 159 |
+
return self.processor.apply_chat_template(
|
| 160 |
+
[{"role": "user", "content": content}],
|
| 161 |
+
tokenize=False,
|
| 162 |
+
add_generation_prompt=True,
|
| 163 |
+
enable_thinking=False,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def training_spec(self, row, seed):
|
| 167 |
+
inp, task = row["input"], row["input"]["answer_space"]["kind"]
|
| 168 |
+
text, count, mapping = self.initial(inp, seed)
|
| 169 |
+
images = self.load_images(inp)
|
| 170 |
+
original, box = images[0], ROOT_BOX
|
| 171 |
+
if task == "choice":
|
| 172 |
+
path = [mapping.index(row["target"]["option_id"])]
|
| 173 |
+
elif task == "scalar":
|
| 174 |
+
path = scalar_path(row["target"]["value"], self.codebook)
|
| 175 |
+
else:
|
| 176 |
+
path = point_path(row["target"]["points"][0])
|
| 177 |
+
counts = [count]
|
| 178 |
+
for stage in range(1, len(path)):
|
| 179 |
+
if task == "point":
|
| 180 |
+
box = child_box(box, path[stage - 1])
|
| 181 |
+
question, count = self.point_prompt(inp, box, stage), 9
|
| 182 |
+
extra = [self.crop(original, box)]
|
| 183 |
+
else:
|
| 184 |
+
question, count = self.scalar_prompt(inp, path[0])
|
| 185 |
+
extra = []
|
| 186 |
+
text += f"{path[stage - 1] + 1}<|im_end|>\n" + self.render(question, len(extra))
|
| 187 |
+
images.extend(extra)
|
| 188 |
+
counts.append(count)
|
| 189 |
+
return {
|
| 190 |
+
"text": text,
|
| 191 |
+
"images": images,
|
| 192 |
+
"counts": counts,
|
| 193 |
+
"targets": path,
|
| 194 |
+
"sample_id": row["sample_id"],
|
| 195 |
+
"weight": row.get("_weight", 1.0),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
def encode_specs(self, specs, training=True):
|
| 199 |
+
encoded = dict(
|
| 200 |
+
self.processor(
|
| 201 |
+
text=[s["text"] for s in specs],
|
| 202 |
+
images=[im for s in specs for im in s["images"]],
|
| 203 |
+
padding=True,
|
| 204 |
+
return_tensors="pt",
|
| 205 |
+
device="cpu",
|
| 206 |
+
)
|
| 207 |
+
)
|
| 208 |
+
encoded.pop("token_type_ids", None)
|
| 209 |
+
lengths = encoded["attention_mask"].sum(-1).tolist()
|
| 210 |
+
if max(lengths) > self.config["max_sequence_length"]:
|
| 211 |
+
raise ValueError(f"Sequence budget exceeded without truncation: {lengths}")
|
| 212 |
+
positions, counts, targets, weights = [], [], [], []
|
| 213 |
+
if training:
|
| 214 |
+
for index, spec in enumerate(specs):
|
| 215 |
+
ids = encoded["input_ids"][index, : lengths[index]].tolist()
|
| 216 |
+
ends = [
|
| 217 |
+
i + len(self.header) - 1
|
| 218 |
+
for i in range(len(ids) - len(self.header) + 1)
|
| 219 |
+
if ids[i : i + len(self.header)] == self.header
|
| 220 |
+
]
|
| 221 |
+
if len(ends) != len(spec["targets"]):
|
| 222 |
+
raise ValueError(f"Decision positions mismatch: {spec['sample_id']}")
|
| 223 |
+
positions.extend((index, p) for p in ends)
|
| 224 |
+
counts.extend(spec["counts"])
|
| 225 |
+
targets.extend(spec["targets"])
|
| 226 |
+
weights.extend([spec["weight"] / len(ends)] * len(ends))
|
| 227 |
+
else:
|
| 228 |
+
positions = [(i, n - 1) for i, n in enumerate(lengths)]
|
| 229 |
+
counts = [s["count"] for s in specs]
|
| 230 |
+
return {
|
| 231 |
+
"inputs": encoded,
|
| 232 |
+
"positions": torch.tensor(positions),
|
| 233 |
+
"counts": torch.tensor(counts),
|
| 234 |
+
"targets": torch.tensor(targets),
|
| 235 |
+
"weights": torch.tensor(weights, dtype=torch.float32),
|
| 236 |
+
"qa_count": sum(s.get("weight", 1.0) for s in specs),
|
| 237 |
+
"tokens": sum(lengths),
|
| 238 |
+
"padded_tokens": encoded["input_ids"].numel(),
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
def collate(self, rows):
|
| 242 |
+
return self.encode_specs(
|
| 243 |
+
[
|
| 244 |
+
self.training_spec(row, sample_seed(row["sample_id"], self.config["seed"]))
|
| 245 |
+
for row in rows
|
| 246 |
+
]
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@torch.inference_mode()
|
| 251 |
+
def predict_batch(model, builder, requests, device):
|
| 252 |
+
"""Free rollout: this function accepts inputs and IDs, never target labels."""
|
| 253 |
+
states, results = [], [None] * len(requests)
|
| 254 |
+
for index, request in enumerate(requests):
|
| 255 |
+
inp = request["input"]
|
| 256 |
+
text, count, mapping = builder.initial(
|
| 257 |
+
inp, sample_seed(request["id"], builder.config["seed"])
|
| 258 |
+
)
|
| 259 |
+
states.append(
|
| 260 |
+
{
|
| 261 |
+
"index": index,
|
| 262 |
+
"input": inp,
|
| 263 |
+
"text": text,
|
| 264 |
+
"count": count,
|
| 265 |
+
"mapping": mapping,
|
| 266 |
+
"images": builder.load_images(inp),
|
| 267 |
+
"path": [],
|
| 268 |
+
"logits": [],
|
| 269 |
+
"box": ROOT_BOX,
|
| 270 |
+
}
|
| 271 |
+
)
|
| 272 |
+
while states:
|
| 273 |
+
packed = builder.encode_specs(states, training=False)
|
| 274 |
+
logits = model(
|
| 275 |
+
move_inputs(packed["inputs"], device),
|
| 276 |
+
packed["positions"].to(device),
|
| 277 |
+
packed["counts"].to(device),
|
| 278 |
+
)
|
| 279 |
+
choices = logits.argmax(-1).tolist()
|
| 280 |
+
following = []
|
| 281 |
+
for state, choice, scores in zip(states, choices, logits.float().cpu()):
|
| 282 |
+
path, inp = state["path"], state["input"]
|
| 283 |
+
path.append(choice)
|
| 284 |
+
state["logits"].append(scores[: state["count"]].tolist())
|
| 285 |
+
task = inp["answer_space"]["kind"]
|
| 286 |
+
done = (
|
| 287 |
+
task == "choice"
|
| 288 |
+
or (task == "point" and len(path) == 3)
|
| 289 |
+
or (
|
| 290 |
+
task == "scalar"
|
| 291 |
+
and (len(path) == 2 or choice in (0, len(builder.codebook["edges_m"])))
|
| 292 |
+
)
|
| 293 |
+
)
|
| 294 |
+
if done:
|
| 295 |
+
prediction = (
|
| 296 |
+
state["mapping"][choice]
|
| 297 |
+
if task == "choice"
|
| 298 |
+
else decode_point(path)
|
| 299 |
+
if task == "point"
|
| 300 |
+
else decode_scalar(path, builder.codebook)
|
| 301 |
+
)
|
| 302 |
+
results[state["index"]] = {
|
| 303 |
+
"prediction": prediction,
|
| 304 |
+
"path": path,
|
| 305 |
+
"logits": state["logits"],
|
| 306 |
+
}
|
| 307 |
+
continue
|
| 308 |
+
if task == "point":
|
| 309 |
+
state["box"] = child_box(state["box"], choice)
|
| 310 |
+
question, count = builder.point_prompt(inp, state["box"], len(path)), 9
|
| 311 |
+
extra = [builder.crop(state["images"][0], state["box"])]
|
| 312 |
+
else:
|
| 313 |
+
question, count = builder.scalar_prompt(inp, choice)
|
| 314 |
+
extra = []
|
| 315 |
+
state["text"] += f"{choice + 1}<|im_end|>\n" + builder.render(question, len(extra))
|
| 316 |
+
state["images"].extend(extra)
|
| 317 |
+
state["count"] = count
|
| 318 |
+
following.append(state)
|
| 319 |
+
states = following
|
| 320 |
+
return results
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def save_checkpoint(model, processor, config, path, step):
|
| 324 |
+
from safetensors.torch import save_file
|
| 325 |
+
|
| 326 |
+
path = Path(path)
|
| 327 |
+
path.mkdir(parents=True, exist_ok=False)
|
| 328 |
+
mode = train_mode(config)
|
| 329 |
+
if mode == "full_language":
|
| 330 |
+
model.backbone.language_model.save_pretrained(
|
| 331 |
+
path / "language_model", safe_serialization=True, max_shard_size="4GB"
|
| 332 |
+
)
|
| 333 |
+
else:
|
| 334 |
+
model.backbone.save_pretrained(path / "adapter", safe_serialization=True)
|
| 335 |
+
save_file(
|
| 336 |
+
{k: v.detach().cpu().contiguous() for k, v in model.classifier.state_dict().items()},
|
| 337 |
+
str(path / "classifier.safetensors"),
|
| 338 |
+
)
|
| 339 |
+
processor.save_pretrained(path / "processor")
|
| 340 |
+
(path / "config.json").write_text(json.dumps(config, indent=2) + "\n")
|
| 341 |
+
(path / "checkpoint.json").write_text(
|
| 342 |
+
json.dumps(
|
| 343 |
+
{
|
| 344 |
+
"step": step,
|
| 345 |
+
"format": f"qwen35-{mode}-shared-classifier",
|
| 346 |
+
"train_mode": mode,
|
| 347 |
+
"base_revision": config["base_revision"],
|
| 348 |
+
},
|
| 349 |
+
indent=2,
|
| 350 |
+
)
|
| 351 |
+
+ "\n"
|
| 352 |
+
)
|
src/spatial_jev/roi_replay.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local original-image token replay, optionally transplanting attention K/V.
|
| 2 |
+
|
| 3 |
+
No pixels are cropped/resized after the original image. Copy merged vision
|
| 4 |
+
tokens whose support intersects the current rectangle, in original row-major
|
| 5 |
+
order, retaining original M-RoPE coordinates. The KV variant also gathers the
|
| 6 |
+
original post-RoPE K and V at every intact full-attention layer. DeltaNet, Q,
|
| 7 |
+
gates, residuals and MLPs are computed normally; this is a representation ablation,
|
| 8 |
+
not a claim that all computations at replayed token slots have been eliminated.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
VIT_REPLAY = 'roi_vit_replay'
|
| 16 |
+
KV_REPLAY = 'roi_attention_kv_replay'
|
| 17 |
+
REPLAY_VARIANTS = (VIT_REPLAY, KV_REPLAY)
|
| 18 |
+
EMPTY_ROI_IMAGE = '<|vision_start|><|vision_end|>'
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def roi_cells(box, shape):
|
| 22 |
+
"""Positive-area overlap with a normalized rectangle, no target-dependent zoom."""
|
| 23 |
+
h, w = map(int, shape)
|
| 24 |
+
x0, y0, x1, y1 = box
|
| 25 |
+
if h < 1 or w < 1 or not (0 <= x0 < x1 <= 1 and 0 <= y0 < y1 <= 1):
|
| 26 |
+
raise ValueError('Invalid original-image ROI/grid')
|
| 27 |
+
# Avoid including a neighboring cell from floating-point noise at exact edges.
|
| 28 |
+
left, top = math.floor(x0*w+1e-9), math.floor(y0*h+1e-9)
|
| 29 |
+
right, bottom = math.ceil(x1*w-1e-9), math.ceil(y1*h-1e-9)
|
| 30 |
+
return [y*w+x for y in range(top, bottom) for x in range(left, right)]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def expand_replay_inputs(builder, specs, encoded):
|
| 34 |
+
"""Insert virtual local-image slots after processing only the real original(s).
|
| 35 |
+
|
| 36 |
+
Base input tensors are retained solely for authoritative native M-RoPE creation;
|
| 37 |
+
gather maps align expanded slots, original visual features and original positions.
|
| 38 |
+
The ordinary causal mask still uses SEQUENCE order, not repeated spatial IDs.
|
| 39 |
+
"""
|
| 40 |
+
if not any(s.get('roi_boxes') for s in specs):
|
| 41 |
+
return encoded
|
| 42 |
+
tokenizer = builder.processor.tokenizer
|
| 43 |
+
image_id, start_id, end_id = [tokenizer.convert_tokens_to_ids(s) for s in
|
| 44 |
+
('<|image_pad|>', '<|vision_start|>', '<|vision_end|>')]
|
| 45 |
+
merge = builder.processor.image_processor.merge_size
|
| 46 |
+
original_ids, original_mask = encoded['input_ids'], encoded['attention_mask']
|
| 47 |
+
original_types = encoded.get('mm_token_type_ids', (original_ids == image_id).long())
|
| 48 |
+
new_ids, rope_sources, kv_sources, feature_sources = [], [], [], []
|
| 49 |
+
grid_cursor = feature_cursor = 0
|
| 50 |
+
for row, spec in enumerate(specs):
|
| 51 |
+
n = int(original_mask[row].sum())
|
| 52 |
+
ids = original_ids[row, :n].tolist()
|
| 53 |
+
grids = encoded['image_grid_thw'][grid_cursor:grid_cursor+len(spec['images'])]
|
| 54 |
+
n_features = sum(int(g.prod())//merge**2 for g in grids)
|
| 55 |
+
image_positions = [i for i, token in enumerate(ids) if token == image_id]
|
| 56 |
+
if len(image_positions) != n_features:
|
| 57 |
+
raise ValueError('Original visual token/grid count mismatch')
|
| 58 |
+
original_feature = {pos: feature_cursor+i for i, pos in enumerate(image_positions)}
|
| 59 |
+
boxes = spec.get('roi_boxes', [])
|
| 60 |
+
selected = []
|
| 61 |
+
if boxes:
|
| 62 |
+
if len(grids) != 1 or int(grids[0, 0]) != 1:
|
| 63 |
+
raise ValueError('ROI replay requires one still original image')
|
| 64 |
+
shape = (int(grids[0, 1])//merge, int(grids[0, 2])//merge)
|
| 65 |
+
selected = [roi_cells(box, shape) for box in boxes]
|
| 66 |
+
tokens, positions, aliases = [], [], []
|
| 67 |
+
slot = 0
|
| 68 |
+
for i, token in enumerate(ids):
|
| 69 |
+
if token == end_id and i and ids[i-1] == start_id:
|
| 70 |
+
if slot >= len(selected):
|
| 71 |
+
raise ValueError('Unregistered local-image slot')
|
| 72 |
+
for cell in selected[slot]:
|
| 73 |
+
source = image_positions[cell]
|
| 74 |
+
tokens.append(image_id)
|
| 75 |
+
positions.append(source)
|
| 76 |
+
# All originals precede every insertion, hence their sequence
|
| 77 |
+
# indices remain identical in the expanded row.
|
| 78 |
+
if source >= len(tokens)-1:
|
| 79 |
+
raise ValueError('Replay may only reference earlier original tokens')
|
| 80 |
+
aliases.append(source)
|
| 81 |
+
feature_sources.append(feature_cursor+cell)
|
| 82 |
+
slot += 1
|
| 83 |
+
tokens.append(token)
|
| 84 |
+
positions.append(i)
|
| 85 |
+
aliases.append(len(tokens)-1)
|
| 86 |
+
if token == image_id:
|
| 87 |
+
feature_sources.append(original_feature[i])
|
| 88 |
+
if slot != len(boxes):
|
| 89 |
+
raise ValueError('Missing local-image marker')
|
| 90 |
+
new_ids.append(tokens)
|
| 91 |
+
rope_sources.append(positions)
|
| 92 |
+
kv_sources.append(aliases)
|
| 93 |
+
grid_cursor += len(grids)
|
| 94 |
+
feature_cursor += n_features
|
| 95 |
+
length = max(map(len, new_ids))
|
| 96 |
+
if length > builder.config['max_sequence_length']:
|
| 97 |
+
raise ValueError('ROI replay exceeds sequence budget; refusing truncation')
|
| 98 |
+
batch = len(specs)
|
| 99 |
+
ids = original_ids.new_full((batch, length), tokenizer.pad_token_id)
|
| 100 |
+
mask = original_mask.new_zeros((batch, length))
|
| 101 |
+
rope = original_ids.new_zeros((batch, length))
|
| 102 |
+
aliases = torch.arange(length, dtype=original_ids.dtype).expand(batch, -1).clone()
|
| 103 |
+
for row, tokens in enumerate(new_ids):
|
| 104 |
+
n = len(tokens)
|
| 105 |
+
ids[row, :n] = torch.tensor(tokens)
|
| 106 |
+
mask[row, :n] = 1
|
| 107 |
+
rope[row, :n] = torch.tensor(rope_sources[row])
|
| 108 |
+
aliases[row, :n] = torch.tensor(kv_sources[row])
|
| 109 |
+
encoded.update(input_ids=ids, attention_mask=mask, mm_token_type_ids=(ids == image_id).long(),
|
| 110 |
+
roi_base_ids=original_ids, roi_base_mask=original_mask, roi_base_types=original_types,
|
| 111 |
+
roi_position_sources=rope, roi_kv_sources=aliases,
|
| 112 |
+
roi_feature_sources=torch.tensor(feature_sources, dtype=torch.long))
|
| 113 |
+
return encoded
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class CopyAttentionKV:
|
| 117 |
+
"""Stateless, differentiable aliasing at the native post-RoPE cache boundary."""
|
| 118 |
+
|
| 119 |
+
def __init__(self, sources):
|
| 120 |
+
self.sources = sources
|
| 121 |
+
self.calls = 0
|
| 122 |
+
|
| 123 |
+
def update(self, keys, values, layer_idx, *args, **kwargs):
|
| 124 |
+
if keys.shape[:3] != values.shape[:3] or keys.shape[0] != self.sources.shape[0] or keys.shape[2] != self.sources.shape[1]:
|
| 125 |
+
raise ValueError('KV replay shape mismatch')
|
| 126 |
+
self.calls += 1
|
| 127 |
+
def gather(tensor):
|
| 128 |
+
indices = self.sources[:, None, :, None].expand(-1, tensor.shape[1], -1, tensor.shape[-1])
|
| 129 |
+
return tensor.gather(2, indices)
|
| 130 |
+
return gather(keys), gather(values)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def kv_replay_hidden(lm, hidden, positions, attention_mask, sources):
|
| 134 |
+
"""Scoped native-attention hooks; never modify recurrent/conv states or weights."""
|
| 135 |
+
if lm.gradient_checkpointing:
|
| 136 |
+
raise ValueError('Scoped KV replay requires gradient_checkpointing=False')
|
| 137 |
+
cache, handles, count = CopyAttentionKV(sources), [], 0
|
| 138 |
+
def inject(module, args, kwargs):
|
| 139 |
+
if kwargs.get('past_key_values') is not None:
|
| 140 |
+
raise ValueError('ROI replay must not mix with a persistent prefix cache')
|
| 141 |
+
return args, {**kwargs, 'past_key_values': cache}
|
| 142 |
+
try:
|
| 143 |
+
for layer in lm.layers:
|
| 144 |
+
if layer.block_type == 'full_attention':
|
| 145 |
+
count += 1
|
| 146 |
+
handles.append(layer.self_attn.register_forward_pre_hook(inject, with_kwargs=True))
|
| 147 |
+
result = lm(inputs_embeds=hidden, position_ids=positions,
|
| 148 |
+
attention_mask=attention_mask, use_cache=False).last_hidden_state
|
| 149 |
+
if cache.calls != count or not count:
|
| 150 |
+
raise ValueError('Every intact full-attention layer must copy its own K/V')
|
| 151 |
+
return result
|
| 152 |
+
finally:
|
| 153 |
+
for handle in handles:
|
| 154 |
+
handle.remove()
|
src/spatial_jev/schema.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed pilot records, strict target parsing and answer-free prompt construction."""
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
import math
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
SCHEMA_VERSION = "spatial-decision/v2"
|
| 8 |
+
EXTENDED_SCHEMA_VERSION = "spatial-decision/v3"
|
| 9 |
+
TASKS = ("choice", "scalar", "point")
|
| 10 |
+
UNIT_FACTORS = {"m": 1.0, "meter": 1.0, "meters": 1.0,
|
| 11 |
+
"cm": 0.01, "centimeter": 0.01, "centimeters": 0.01,
|
| 12 |
+
"mm": 0.001, "millimeter": 0.001, "millimeters": 0.001,
|
| 13 |
+
"inch": 0.0254, "inches": 0.0254, "ft": 0.3048,
|
| 14 |
+
"foot": 0.3048, "feet": 0.3048}
|
| 15 |
+
_NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_measurement(answer, question=""):
|
| 19 |
+
"""Accept a scalar, optionally with an explicit length unit; never extract CoT numbers."""
|
| 20 |
+
match = re.fullmatch(rf"\s*({_NUMBER})\s*([a-zA-Z]+)?\s*\.?\s*", answer)
|
| 21 |
+
if not match:
|
| 22 |
+
raise ValueError("not_a_scalar_answer")
|
| 23 |
+
unit = match.group(2)
|
| 24 |
+
if unit is None:
|
| 25 |
+
units = re.findall(r"\b(" + "|".join(UNIT_FACTORS) + r")\b", question.lower())
|
| 26 |
+
factors = {UNIT_FACTORS[u] for u in units}
|
| 27 |
+
if len(factors) != 1:
|
| 28 |
+
raise ValueError("missing_or_ambiguous_unit")
|
| 29 |
+
unit = units[-1]
|
| 30 |
+
unit = unit.lower()
|
| 31 |
+
if unit not in UNIT_FACTORS:
|
| 32 |
+
raise ValueError("unsupported_unit")
|
| 33 |
+
value = float(match.group(1)) * UNIT_FACTORS[unit]
|
| 34 |
+
if not math.isfinite(value) or value < 0:
|
| 35 |
+
raise ValueError("invalid_measurement")
|
| 36 |
+
return value, unit
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def parse_single_point(answer):
|
| 40 |
+
try:
|
| 41 |
+
value = ast.literal_eval(answer.strip())
|
| 42 |
+
except (ValueError, SyntaxError) as exc:
|
| 43 |
+
raise ValueError("not_a_point_literal") from exc
|
| 44 |
+
if not isinstance(value, (list, tuple)) or len(value) != 1:
|
| 45 |
+
raise ValueError("not_a_single_point")
|
| 46 |
+
point = value[0]
|
| 47 |
+
if not isinstance(point, (list, tuple)) or len(point) != 2:
|
| 48 |
+
raise ValueError("not_xy")
|
| 49 |
+
if any(isinstance(x, bool) or not isinstance(x, (int, float)) for x in point):
|
| 50 |
+
raise ValueError("non_numeric_point")
|
| 51 |
+
if any(not math.isfinite(x) or not 0 <= x <= 1 for x in point):
|
| 52 |
+
raise ValueError("point_out_of_range")
|
| 53 |
+
return [float(x) for x in point]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def clean_question(question):
|
| 57 |
+
question = question.replace("<image>", "").strip()
|
| 58 |
+
question = re.split(r"\s*Your answer should be formatted", question, flags=re.I)[0]
|
| 59 |
+
question = re.sub(r"\s*Answer the question using a single word or phrase\.?", "",
|
| 60 |
+
question, flags=re.I)
|
| 61 |
+
return question.strip()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def canonical_metric_question(question):
|
| 65 |
+
question = clean_question(question)
|
| 66 |
+
# Do not rewrite numeric lengths in a premise without also converting their values.
|
| 67 |
+
if re.search(rf"{_NUMBER}\s*(?:cm|mm|meters?|centimeters?|inches|feet)\b",
|
| 68 |
+
question, re.I):
|
| 69 |
+
raise ValueError("numeric_unit_in_question_premise")
|
| 70 |
+
return re.sub(r"\b(?:centimeters?|cm|millimeters?|mm|inches|feet|foot|ft)\b",
|
| 71 |
+
"meters", question, flags=re.I)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def validate_record(record, max_choices=32):
|
| 75 |
+
if record.get("schema_version") not in (SCHEMA_VERSION, EXTENDED_SCHEMA_VERSION):
|
| 76 |
+
raise ValueError("unsupported_schema")
|
| 77 |
+
inp = record["input"]
|
| 78 |
+
if not inp["question"].strip() or not inp["media"]:
|
| 79 |
+
raise ValueError("empty_input")
|
| 80 |
+
if any(m["kind"] != "image" for m in inp["media"]):
|
| 81 |
+
raise ValueError("pilot_requires_images")
|
| 82 |
+
space, target = inp["answer_space"], record["target"]
|
| 83 |
+
kind = space["kind"]
|
| 84 |
+
if kind == "choice":
|
| 85 |
+
options = space["options"]
|
| 86 |
+
ids = [o["id"] for o in options]
|
| 87 |
+
if not 2 <= len(options) <= max_choices or len(set(ids)) != len(ids):
|
| 88 |
+
raise ValueError("invalid_options")
|
| 89 |
+
if target["option_id"] not in ids:
|
| 90 |
+
raise ValueError("target_missing_from_options")
|
| 91 |
+
elif kind == "scalar":
|
| 92 |
+
allowed = ("m", "count", "deg") if record["schema_version"] == EXTENDED_SCHEMA_VERSION else ("m",)
|
| 93 |
+
if space["unit"] not in allowed or target["unit"] != space["unit"]:
|
| 94 |
+
raise ValueError("pilot_scalar_requires_meters")
|
| 95 |
+
if isinstance(target["value"], bool) or not math.isfinite(target["value"]) or target["value"] < 0:
|
| 96 |
+
raise ValueError("invalid_scalar")
|
| 97 |
+
if space["unit"] == "count":
|
| 98 |
+
if space.get("quantity") != "count" or int(target["value"]) != target["value"] or target["value"] > 1023:
|
| 99 |
+
raise ValueError("invalid_integer_count")
|
| 100 |
+
if space["unit"] == "deg":
|
| 101 |
+
if target["value"] > 360 or not space.get("angle_semantics"):
|
| 102 |
+
raise ValueError("invalid_angle_semantics_or_range")
|
| 103 |
+
elif kind == "point":
|
| 104 |
+
if len(inp["media"]) != 1:
|
| 105 |
+
raise ValueError("pilot_point_requires_one_image")
|
| 106 |
+
if space["coordinate_system"] != "normalized_xy" or space["num_points"] != 1:
|
| 107 |
+
raise ValueError("unsupported_point_space")
|
| 108 |
+
parse_single_point(repr(target["points"]))
|
| 109 |
+
else:
|
| 110 |
+
raise ValueError("unsupported_task")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def compile_prompt(inp, permutation=None):
|
| 114 |
+
"""Only input is accepted; targets and provenance cannot enter the prompt."""
|
| 115 |
+
space = inp["answer_space"]
|
| 116 |
+
kind = space["kind"]
|
| 117 |
+
text = f"Question: {inp['question']}\n"
|
| 118 |
+
option_ids = []
|
| 119 |
+
if kind == "choice":
|
| 120 |
+
options = space["options"]
|
| 121 |
+
order = list(range(len(options))) if permutation is None else list(permutation)
|
| 122 |
+
if sorted(order) != list(range(len(options))):
|
| 123 |
+
raise ValueError("invalid_permutation")
|
| 124 |
+
text += "Options:\n"
|
| 125 |
+
for i, index in enumerate(order):
|
| 126 |
+
option = options[index]
|
| 127 |
+
text += f"{i + 1}. {option['text']}\n"
|
| 128 |
+
option_ids.append(option["id"])
|
| 129 |
+
text += "Select exactly one option."
|
| 130 |
+
elif kind == "scalar":
|
| 131 |
+
unit = {"m": "meters", "count": "count units (an integer)", "deg": "degrees"}[space["unit"]]
|
| 132 |
+
text += f"Estimate {space['quantity']}. Return a nonnegative value in {unit}."
|
| 133 |
+
elif kind == "point":
|
| 134 |
+
text += ("Return one valid point (x, y) in the original image, normalized to [0, 1]. "
|
| 135 |
+
"The origin is top-left; x increases rightward and y increases downward.")
|
| 136 |
+
else:
|
| 137 |
+
raise ValueError("unsupported_task")
|
| 138 |
+
return text, option_ids
|
src/spatial_jev/training_alignment.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared controls for the three full-language architecture experiments."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def old_head_factors(config, step, total_steps):
|
| 11 |
+
"""Exactly the old runner's zero-based, pre-optimizer-step LR multipliers."""
|
| 12 |
+
decay = 0.1 + 0.9 * (1 + math.cos(math.pi * step / total_steps)) / 2
|
| 13 |
+
warm = min(1, (step + 1) / config["warmup_steps"])
|
| 14 |
+
backbone_warm = min(1, max(0, step + 1 - config["head_warmup_steps"]) / config["warmup_steps"])
|
| 15 |
+
return backbone_warm * decay, warm * decay
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def seed_head(config, factory):
|
| 19 |
+
if "head_seed" not in config:
|
| 20 |
+
return factory()
|
| 21 |
+
with torch.random.fork_rng(devices=[]):
|
| 22 |
+
torch.random.default_generator.manual_seed(config["head_seed"])
|
| 23 |
+
return factory()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def state_hash(module):
|
| 27 |
+
return hashlib.sha256(
|
| 28 |
+
b"".join(
|
| 29 |
+
value.detach().float().cpu().contiguous().numpy().tobytes()
|
| 30 |
+
for value in module.state_dict().values()
|
| 31 |
+
)
|
| 32 |
+
).hexdigest()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def canonical_rows(rows, path, expected_sha256):
|
| 36 |
+
"""Restore the ORIGINAL flat order before shuffling, including padding row 0."""
|
| 37 |
+
if hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha256:
|
| 38 |
+
raise ValueError("Canonical training file hash changed")
|
| 39 |
+
by_id = {row["sample_id"]: row for row in rows}
|
| 40 |
+
identities = []
|
| 41 |
+
with path.open() as stream:
|
| 42 |
+
for line in stream:
|
| 43 |
+
source = json.loads(line)
|
| 44 |
+
identity = source["sample_id"]
|
| 45 |
+
identities.append(identity)
|
| 46 |
+
if identity not in by_id or any(
|
| 47 |
+
source.get(key) != by_id[identity].get(key) for key in ("input", "target")
|
| 48 |
+
):
|
| 49 |
+
raise ValueError(f"Canonical QA contents differ: {identity}")
|
| 50 |
+
if (
|
| 51 |
+
len(by_id) != len(rows)
|
| 52 |
+
or len(set(identities)) != len(identities)
|
| 53 |
+
or set(identities) != by_id.keys()
|
| 54 |
+
):
|
| 55 |
+
raise ValueError("Canonical QA identities do not match the training dataset")
|
| 56 |
+
return [by_id[identity] for identity in identities]
|
src/spatial_jev/unified.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One shared classifier with scalar interval decisions and three-step pointing."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import random
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from PIL import Image
|
| 10 |
+
from torch import nn
|
| 11 |
+
|
| 12 |
+
from .hierarchy import (POINT_NAMES, ROOT_BOX, child_box, decode_point, decode_scalar,
|
| 13 |
+
point_path, roi_visible, scalar_fine_bins, scalar_interval, scalar_path,
|
| 14 |
+
visual_token_boxes)
|
| 15 |
+
from .schema import compile_prompt
|
| 16 |
+
from .numeric_tasks import codebook_for, numeric_prompt
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def causal_visual_mask(length, visual_types, stages, query_start=0):
|
| 20 |
+
"""Causal text plus bidirectional vision ONLY within the same reveal stage.
|
| 21 |
+
|
| 22 |
+
Native Molmo2's all-image bidirectionality would leak future GT-selected crops.
|
| 23 |
+
Old prefix queries are not recomputed during incremental inference.
|
| 24 |
+
"""
|
| 25 |
+
keys = torch.arange(length)
|
| 26 |
+
queries = keys[query_start:]
|
| 27 |
+
allowed = keys[None] <= queries[:, None]
|
| 28 |
+
visual_types = torch.as_tensor(visual_types, dtype=torch.bool)
|
| 29 |
+
stages = torch.as_tensor(stages, dtype=torch.long)
|
| 30 |
+
allowed |= (visual_types[query_start:, None] & visual_types[None]
|
| 31 |
+
& (stages[query_start:, None] == stages[None]))
|
| 32 |
+
return torch.zeros((1, 1, len(queries), length)).masked_fill(~allowed[None, None], -torch.inf)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def apply_roi_mask(mask, visual_positions, boxes, roi, start=0, end=None):
|
| 36 |
+
keep = roi_visible(boxes, roi)
|
| 37 |
+
if not keep.any():
|
| 38 |
+
raise ValueError("ROI has no visual support")
|
| 39 |
+
blocked = torch.as_tensor(np.asarray(visual_positions)[~keep], dtype=torch.long)
|
| 40 |
+
mask[:, :, start:end, blocked] = -torch.inf
|
| 41 |
+
return int(keep.sum())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def move_inputs(inputs, device):
|
| 45 |
+
return {key: value.to(device=device, dtype=torch.bfloat16)
|
| 46 |
+
if value.is_floating_point() else value.to(device) for key, value in inputs.items()}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def collate_training(examples, pad_id):
|
| 50 |
+
"""Pad independent examples without cross-example attention or image-pooling offsets."""
|
| 51 |
+
length = max(p["inputs"]["input_ids"].shape[1] for p in examples)
|
| 52 |
+
batch = len(examples)
|
| 53 |
+
ids = torch.full((batch, length), pad_id, dtype=torch.long)
|
| 54 |
+
mask = torch.full((batch, 1, length, length), -torch.inf)
|
| 55 |
+
positions, counts, targets, weights = [], [], [], []
|
| 56 |
+
for index, packed in enumerate(examples):
|
| 57 |
+
n = packed["inputs"]["input_ids"].shape[1]
|
| 58 |
+
ids[index, :n] = packed["inputs"]["input_ids"][0]
|
| 59 |
+
mask[index, :, :n, :n] = packed["inputs"]["attention_mask"][0]
|
| 60 |
+
padding = torch.arange(n, length)
|
| 61 |
+
mask[index, 0, padding, padding] = 0
|
| 62 |
+
positions.extend((index, int(p)) for p in packed["positions"])
|
| 63 |
+
counts.extend(packed["counts"].tolist())
|
| 64 |
+
targets.extend(packed["targets"].tolist())
|
| 65 |
+
weights.extend([1 / len(packed["targets"])] * len(packed["targets"]))
|
| 66 |
+
inputs = {"input_ids": ids, "attention_mask": mask}
|
| 67 |
+
for key in ("pixel_values", "image_token_pooling", "image_grids", "image_num_crops"):
|
| 68 |
+
# Native pooling indices are local to each image; the model adds offsets itself.
|
| 69 |
+
inputs[key] = torch.cat([p["inputs"][key] for p in examples], dim=0)
|
| 70 |
+
return {"inputs": inputs, "positions": torch.tensor(positions), "counts": torch.tensor(counts),
|
| 71 |
+
"targets": torch.tensor(targets), "loss_weights": torch.tensor(weights) / batch}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class SharedClassifier(nn.Module):
|
| 75 |
+
def __init__(self, hidden_size, max_choices=32):
|
| 76 |
+
super().__init__()
|
| 77 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 78 |
+
self.linear = nn.Linear(hidden_size, max_choices)
|
| 79 |
+
|
| 80 |
+
def forward(self, hidden, counts):
|
| 81 |
+
with torch.autocast(device_type=hidden.device.type, enabled=False):
|
| 82 |
+
logits = self.linear(self.norm(hidden.float()))
|
| 83 |
+
invalid = torch.arange(logits.shape[-1], device=logits.device)[None] >= counts[:, None]
|
| 84 |
+
return logits.masked_fill(invalid, -torch.inf)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class UnifiedSpatialModel(nn.Module):
|
| 88 |
+
def __init__(self, backbone, classifier):
|
| 89 |
+
super().__init__()
|
| 90 |
+
self.backbone, self.classifier = backbone, classifier
|
| 91 |
+
|
| 92 |
+
def forward(self, inputs, positions, counts):
|
| 93 |
+
out = self.backbone(**inputs, use_cache=False)
|
| 94 |
+
hidden = (out.last_hidden_state[positions[:, 0], positions[:, 1]] if positions.ndim == 2
|
| 95 |
+
else out.last_hidden_state[0, positions])
|
| 96 |
+
return self.classifier(hidden, counts)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build_unified_model(config, checkpoint=None, trainable=True):
|
| 100 |
+
from peft import LoraConfig, PeftModel, get_peft_model
|
| 101 |
+
from transformers import AutoModelForImageTextToText
|
| 102 |
+
|
| 103 |
+
full = AutoModelForImageTextToText.from_pretrained(
|
| 104 |
+
config["base_model"], trust_remote_code=True, local_files_only=True,
|
| 105 |
+
dtype=torch.bfloat16, attn_implementation="sdpa")
|
| 106 |
+
backbone, hidden = full.model, full.config.text_config.hidden_size
|
| 107 |
+
del full
|
| 108 |
+
if checkpoint:
|
| 109 |
+
backbone = PeftModel.from_pretrained(backbone, str(Path(checkpoint) / "adapter"),
|
| 110 |
+
is_trainable=trainable)
|
| 111 |
+
else:
|
| 112 |
+
backbone = get_peft_model(backbone, LoraConfig(
|
| 113 |
+
r=config["lora_rank"], lora_alpha=config["lora_alpha"],
|
| 114 |
+
lora_dropout=config["lora_dropout"], target_modules=config["lora_targets"], bias="none"))
|
| 115 |
+
classifier = SharedClassifier(hidden, config["max_choices"])
|
| 116 |
+
if checkpoint:
|
| 117 |
+
from safetensors.torch import load_file
|
| 118 |
+
classifier.load_state_dict(load_file(str(Path(checkpoint) / "classifier.safetensors")))
|
| 119 |
+
if trainable and config.get("gradient_checkpointing", True):
|
| 120 |
+
backbone.get_base_model().gradient_checkpointing_enable(
|
| 121 |
+
gradient_checkpointing_kwargs={"use_reentrant": False})
|
| 122 |
+
model = UnifiedSpatialModel(backbone, classifier)
|
| 123 |
+
if any(p.requires_grad for n, p in backbone.named_parameters() if "vision_backbone" in n):
|
| 124 |
+
raise ValueError("Visual encoder/projector must be frozen in this ablation")
|
| 125 |
+
if not trainable:
|
| 126 |
+
model.requires_grad_(False).eval()
|
| 127 |
+
return model
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def save_unified_checkpoint(model, processor, config, output, step, optimizer=None):
|
| 131 |
+
from safetensors.torch import save_file
|
| 132 |
+
|
| 133 |
+
output = Path(output)
|
| 134 |
+
output.mkdir(parents=True, exist_ok=False)
|
| 135 |
+
model.backbone.save_pretrained(output / "adapter", safe_serialization=True)
|
| 136 |
+
save_file({k: v.detach().cpu().contiguous() for k, v in model.classifier.state_dict().items()},
|
| 137 |
+
str(output / "classifier.safetensors"))
|
| 138 |
+
processor.save_pretrained(output / "processor")
|
| 139 |
+
(output / "config.json").write_text(json.dumps(config, indent=2) + "\n")
|
| 140 |
+
if optimizer:
|
| 141 |
+
torch.save({"step": step, "optimizer": optimizer.state_dict()}, output / "optimizer.pt")
|
| 142 |
+
(output / "checkpoint.json").write_text(json.dumps({
|
| 143 |
+
"step": step, "format": "base_plus_lora_plus_one_classifier",
|
| 144 |
+
"point_variant": config["point_variant"], "base_revision": config["base_revision"],
|
| 145 |
+
"calibrated": False}, indent=2) + "\n")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class UnifiedBuilder:
|
| 149 |
+
def __init__(self, processor, config):
|
| 150 |
+
self.processor, self.config = processor, config
|
| 151 |
+
self.codebook = config["scalar_codebook"]
|
| 152 |
+
self.variant = config["point_variant"]
|
| 153 |
+
if self.variant not in ("single_image", "crop_refill", "roi_mask"):
|
| 154 |
+
raise ValueError("Unknown point ablation")
|
| 155 |
+
self.patch_id = processor.tokenizer.convert_tokens_to_ids("<im_patch>")
|
| 156 |
+
self.header = processor.tokenizer.encode("<|im_start|>assistant\n", add_special_tokens=False)
|
| 157 |
+
|
| 158 |
+
def render(self, text, image_count):
|
| 159 |
+
content = [{"type": "image"} for _ in range(image_count)]
|
| 160 |
+
content.append({"type": "text", "text": text})
|
| 161 |
+
# Render each reveal separately. Rendering all messages at once hoists future crops.
|
| 162 |
+
return self.processor.apply_chat_template([{"role": "user", "content": content}],
|
| 163 |
+
tokenize=False, add_generation_prompt=True)
|
| 164 |
+
|
| 165 |
+
@staticmethod
|
| 166 |
+
def load_images(inp):
|
| 167 |
+
images = []
|
| 168 |
+
for media in inp["media"]:
|
| 169 |
+
with Image.open(media["uri"]) as image:
|
| 170 |
+
images.append(image.convert("RGB"))
|
| 171 |
+
return images
|
| 172 |
+
|
| 173 |
+
@staticmethod
|
| 174 |
+
def crop(original, box):
|
| 175 |
+
w, h = original.size
|
| 176 |
+
extent = (box[0] * w, box[1] * h, box[2] * w, box[3] * h)
|
| 177 |
+
size = (max(1, round(extent[2] - extent[0])), max(1, round(extent[3] - extent[1])))
|
| 178 |
+
return original.transform(size, Image.Transform.EXTENT, extent, Image.Resampling.BILINEAR)
|
| 179 |
+
|
| 180 |
+
def point_prompt(self, inp, box, level):
|
| 181 |
+
bounds = ", ".join(f"{x:.6f}" for x in box)
|
| 182 |
+
options = "\n".join(f"{i+1}. {name}" for i, name in enumerate(POINT_NAMES))
|
| 183 |
+
return (f"Request: {inp['question']}\nChoose one valid point satisfying the request. "
|
| 184 |
+
f"Localization step {level+1} of 3. The current rectangle in the ORIGINAL image "
|
| 185 |
+
f"is (left, top, right, bottom) = ({bounds}), normalized to [0, 1]. "
|
| 186 |
+
"The origin is top-left, x increases rightward and y downward. "
|
| 187 |
+
"Any image appended after the original is a zoomed view of its current rectangle. "
|
| 188 |
+
"Divide the CURRENT rectangle into three equal rows and three equal columns. "
|
| 189 |
+
f"Which region contains the point?\nOptions:\n{options}\nSelect exactly one option.")
|
| 190 |
+
|
| 191 |
+
def scalar_prompt(self, inp, root=None):
|
| 192 |
+
cb = codebook_for(inp, self.codebook)
|
| 193 |
+
if cb.get("unit", "m") != "m":
|
| 194 |
+
return numeric_prompt(inp, root, cb)
|
| 195 |
+
if root is None:
|
| 196 |
+
options = ["exactly zero meters"]
|
| 197 |
+
options += [f"{lo:g} to {hi:g} meters" for lo, hi in zip(cb["edges_m"], cb["edges_m"][1:])]
|
| 198 |
+
options[1] = f"greater than zero and less than {cb['edges_m'][1]:g} meters"
|
| 199 |
+
options += [f"greater than {cb['edges_m'][-1]:g} meters"]
|
| 200 |
+
instruction = "Select the range containing the nonnegative measurement."
|
| 201 |
+
else:
|
| 202 |
+
lo, hi = scalar_interval(root, cb)
|
| 203 |
+
fine = scalar_fine_bins(root, cb)
|
| 204 |
+
step = (hi - lo) / fine
|
| 205 |
+
options = [f"{lo+i*step:.6g} to {lo+(i+1)*step:.6g} meters"
|
| 206 |
+
for i in range(fine)]
|
| 207 |
+
instruction = f"Refine the previously selected range {lo:g} to {hi:g} meters."
|
| 208 |
+
text = (f"Question: {inp['question']}\n{instruction} "
|
| 209 |
+
"Ranges include the lower endpoint and exclude the upper endpoint, "
|
| 210 |
+
"except that the maximum finite endpoint is included. Exact zero has its own class."
|
| 211 |
+
"\nOptions:\n" + "\n".join(f"{i+1}. {s}" for i, s in enumerate(options))
|
| 212 |
+
+ "\nSelect exactly one option.")
|
| 213 |
+
return text, len(options)
|
| 214 |
+
|
| 215 |
+
def initial(self, inp, seed):
|
| 216 |
+
task, mapping = inp["answer_space"]["kind"], []
|
| 217 |
+
if task == "choice":
|
| 218 |
+
order = list(range(len(inp["answer_space"]["options"])))
|
| 219 |
+
if not self.config.get("preserve_option_order", False):
|
| 220 |
+
random.Random(seed).shuffle(order)
|
| 221 |
+
text, mapping = compile_prompt(inp, order)
|
| 222 |
+
count = len(mapping)
|
| 223 |
+
elif task == "scalar":
|
| 224 |
+
text, count = self.scalar_prompt(inp)
|
| 225 |
+
else:
|
| 226 |
+
text, count = self.point_prompt(inp, ROOT_BOX, 0), 9
|
| 227 |
+
return self.render(text, len(inp["media"])), count, mapping
|
| 228 |
+
|
| 229 |
+
def encode(self, text, images, metadata=False, continuation=False):
|
| 230 |
+
kwargs = {"text": [text], "return_tensors": "pt", "padding": False,
|
| 231 |
+
"return_mm_token_type_ids": True}
|
| 232 |
+
if images:
|
| 233 |
+
kwargs.update(images=images, return_pointing_metadata=metadata)
|
| 234 |
+
encoded = self.processor(**kwargs)
|
| 235 |
+
meta = encoded.pop("metadata", None)
|
| 236 |
+
if continuation:
|
| 237 |
+
bos = self.processor.tokenizer.bos_token_id or self.processor.tokenizer.eos_token_id
|
| 238 |
+
if encoded["input_ids"][0, 0].item() != bos:
|
| 239 |
+
raise ValueError("Unexpected continuation BOS")
|
| 240 |
+
for key in ("input_ids", "attention_mask", "token_type_ids"):
|
| 241 |
+
encoded[key] = encoded[key][:, 1:]
|
| 242 |
+
return dict(encoded), meta
|
| 243 |
+
|
| 244 |
+
def geometry(self, inputs, metadata):
|
| 245 |
+
positions = (inputs["input_ids"][0] == self.patch_id).nonzero().flatten().numpy()
|
| 246 |
+
boxes = visual_token_boxes(metadata, inputs["image_grids"].tolist(),
|
| 247 |
+
inputs["image_num_crops"].tolist(), inputs["pixel_values"].shape[1])
|
| 248 |
+
if len(positions) != len(boxes):
|
| 249 |
+
raise ValueError("Patch positions and geometric supports differ")
|
| 250 |
+
return positions, boxes
|
| 251 |
+
|
| 252 |
+
def training_example(self, row, seed):
|
| 253 |
+
inp, task = row["input"], row["input"]["answer_space"]["kind"]
|
| 254 |
+
text, count, mapping = self.initial(inp, seed)
|
| 255 |
+
images = self.load_images(inp)
|
| 256 |
+
original = images[0]
|
| 257 |
+
if task == "choice":
|
| 258 |
+
path = [mapping.index(row["target"]["option_id"])]
|
| 259 |
+
elif task == "scalar":
|
| 260 |
+
path = scalar_path(row["target"]["value"], self.codebook)
|
| 261 |
+
else:
|
| 262 |
+
path = point_path(row["target"]["points"][0])
|
| 263 |
+
counts, boxes, box = [count], [ROOT_BOX], ROOT_BOX
|
| 264 |
+
for stage in range(1, len(path)):
|
| 265 |
+
if task == "point":
|
| 266 |
+
box = child_box(box, path[stage - 1])
|
| 267 |
+
question, count = self.point_prompt(inp, box, stage), 9
|
| 268 |
+
extra = [self.crop(original, box)] if self.variant == "crop_refill" else []
|
| 269 |
+
else:
|
| 270 |
+
question, count = self.scalar_prompt(inp, path[0])
|
| 271 |
+
extra = []
|
| 272 |
+
text += f"{path[stage-1]+1}<|im_end|>\n" + self.render(question, len(extra))
|
| 273 |
+
images.extend(extra)
|
| 274 |
+
counts.append(count)
|
| 275 |
+
boxes.append(box)
|
| 276 |
+
needs_geometry = task == "point" and self.variant == "roi_mask"
|
| 277 |
+
inputs, metadata = self.encode(text, images, metadata=needs_geometry)
|
| 278 |
+
ids = inputs["input_ids"][0].tolist()
|
| 279 |
+
positions = [i + len(self.header) - 1 for i in range(len(ids) - len(self.header) + 1)
|
| 280 |
+
if ids[i:i+len(self.header)] == self.header]
|
| 281 |
+
if len(positions) != len(path):
|
| 282 |
+
raise ValueError("Decision marker count mismatch")
|
| 283 |
+
if len(ids) > self.config["max_sequence_length"]:
|
| 284 |
+
raise ValueError("Sequence budget exceeded")
|
| 285 |
+
types = inputs.pop("token_type_ids")[0]
|
| 286 |
+
stages = torch.zeros(len(ids), dtype=torch.long)
|
| 287 |
+
for stage in range(1, len(path)):
|
| 288 |
+
stages[positions[stage-1]+1:positions[stage]+1] = stage
|
| 289 |
+
mask = causal_visual_mask(len(ids), types, stages)
|
| 290 |
+
if needs_geometry:
|
| 291 |
+
visual_positions, supports = self.geometry(inputs, metadata)
|
| 292 |
+
for stage in range(1, len(path)):
|
| 293 |
+
apply_roi_mask(mask, visual_positions, supports, boxes[stage],
|
| 294 |
+
positions[stage-1]+1, positions[stage]+1)
|
| 295 |
+
inputs["attention_mask"] = mask
|
| 296 |
+
return {"inputs": inputs, "positions": torch.tensor(positions),
|
| 297 |
+
"counts": torch.tensor(counts), "targets": torch.tensor(path),
|
| 298 |
+
"mapping": mapping, "boxes": boxes}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
@torch.inference_mode()
|
| 302 |
+
def predict_unified(model, builder, inp, device, seed, forced_path=None):
|
| 303 |
+
"""No targets are accepted. forced_path is solely for cache-equivalence integration tests."""
|
| 304 |
+
task = inp["answer_space"]["kind"]
|
| 305 |
+
images = builder.load_images(inp)
|
| 306 |
+
original = images[0]
|
| 307 |
+
text, count, mapping = builder.initial(inp, seed)
|
| 308 |
+
need_geometry = task == "point" and builder.variant == "roi_mask"
|
| 309 |
+
encoded, meta = builder.encode(text, images, metadata=need_geometry)
|
| 310 |
+
geom = builder.geometry(encoded, meta) if need_geometry else None
|
| 311 |
+
all_types, all_stages = [], []
|
| 312 |
+
path, logits_trace, support_counts = [], [], []
|
| 313 |
+
box, cache, previous_length, total_input_tokens = ROOT_BOX, None, 0, 0
|
| 314 |
+
for stage in range(3 if task == "point" else 2 if task == "scalar" else 1):
|
| 315 |
+
types = encoded.pop("token_type_ids")[0].tolist()
|
| 316 |
+
all_types.extend(types)
|
| 317 |
+
all_stages.extend([stage] * len(types))
|
| 318 |
+
total_input_tokens += encoded["input_ids"].shape[1]
|
| 319 |
+
if total_input_tokens > builder.config["max_sequence_length"]:
|
| 320 |
+
raise ValueError("Rollout sequence budget exceeded")
|
| 321 |
+
mask = causal_visual_mask(total_input_tokens, all_types, all_stages, previous_length)
|
| 322 |
+
if need_geometry and stage > 0:
|
| 323 |
+
support_counts.append(apply_roi_mask(mask, *geom, box))
|
| 324 |
+
encoded["attention_mask"] = mask
|
| 325 |
+
inputs = move_inputs(encoded, device)
|
| 326 |
+
inputs["cache_position"] = torch.arange(previous_length, total_input_tokens, device=device)
|
| 327 |
+
output = model.backbone(**inputs, past_key_values=cache, use_cache=True)
|
| 328 |
+
logits = model.classifier(output.last_hidden_state[:, -1],
|
| 329 |
+
torch.tensor([count], device=device))
|
| 330 |
+
logits_trace.append(logits[0, :count].float().cpu().tolist())
|
| 331 |
+
choice = int(logits.argmax(-1).item()) if forced_path is None else forced_path[stage]
|
| 332 |
+
path.append(choice)
|
| 333 |
+
cache, previous_length = output.past_key_values, total_input_tokens
|
| 334 |
+
if task == "choice":
|
| 335 |
+
break
|
| 336 |
+
if task == "scalar":
|
| 337 |
+
if stage == 1 or choice in (0, len(builder.codebook["edges_m"])):
|
| 338 |
+
break
|
| 339 |
+
question, count = builder.scalar_prompt(inp, choice)
|
| 340 |
+
extra = []
|
| 341 |
+
else:
|
| 342 |
+
box = child_box(box, choice)
|
| 343 |
+
if stage == 2:
|
| 344 |
+
break
|
| 345 |
+
question, count = builder.point_prompt(inp, box, stage + 1), 9
|
| 346 |
+
extra = [builder.crop(original, box)] if builder.variant == "crop_refill" else []
|
| 347 |
+
text = f"{choice+1}<|im_end|>\n" + builder.render(question, len(extra))
|
| 348 |
+
encoded, _ = builder.encode(text, extra, continuation=True)
|
| 349 |
+
if task == "choice":
|
| 350 |
+
prediction = mapping[path[0]]
|
| 351 |
+
elif task == "scalar":
|
| 352 |
+
prediction = decode_scalar(path, builder.codebook)
|
| 353 |
+
else:
|
| 354 |
+
prediction = decode_point(path)
|
| 355 |
+
return {"prediction": prediction, "path": path, "logits": logits_trace,
|
| 356 |
+
"mapping": mapping, "input_tokens": total_input_tokens,
|
| 357 |
+
"roi_support_counts": support_counts}
|
src/spatial_jev/visual_token_pointing.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direct question-Q / image-token-K pointing on Qwen's merged visual grid.
|
| 2 |
+
|
| 3 |
+
The candidates are LLM image-token positions, not raw ViT keys (a different
|
| 4 |
+
projection space). Geometry follows image_grid_thw after spatial merging, in
|
| 5 |
+
row-major order, with no crop, label-dependent input, or learned coordinate head.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from torch.nn import functional as F
|
| 13 |
+
|
| 14 |
+
from .point_probability_map import component_points
|
| 15 |
+
from .roi_replay import REPLAY_VARIANTS
|
| 16 |
+
|
| 17 |
+
VISUAL_TOKENS = "visual_tokens"
|
| 18 |
+
ORIGINAL_IMAGE = "original_image_nine_way"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def validate_pointing_config(config):
|
| 22 |
+
variant = config.get("point_variant", "crop_refill")
|
| 23 |
+
if variant not in ("crop_refill", VISUAL_TOKENS, ORIGINAL_IMAGE, *REPLAY_VARIANTS):
|
| 24 |
+
raise ValueError(f"Unsupported KEV pointing method: {variant}")
|
| 25 |
+
if variant == ORIGINAL_IMAGE and config.get("point_depth", 3) != 3:
|
| 26 |
+
raise ValueError("Original-image nine-way pointing requires three decisions")
|
| 27 |
+
if variant in REPLAY_VARIANTS and (
|
| 28 |
+
config.get('point_depth', 3) != 3 or config.get('readout_kind') != 'native_last_qk_mean'
|
| 29 |
+
or config.get('readout_layer_indices') is not None or config.get('readout_layer_index', 31) != 31
|
| 30 |
+
or config.get('shared_prefix', False) or config.get('gradient_checkpointing', False)):
|
| 31 |
+
raise ValueError('ROI replay fixes L32 mean, three decisions and no persistent cache/checkpointing')
|
| 32 |
+
if variant != VISUAL_TOKENS:
|
| 33 |
+
return
|
| 34 |
+
if config.get("point_depth", 1) != 1:
|
| 35 |
+
raise ValueError("Visual-token pointing is a single decision, not a crop tree")
|
| 36 |
+
if config.get("shared_prefix", False):
|
| 37 |
+
raise ValueError("Visual-token keys require the full causal row; shared_prefix is unsupported")
|
| 38 |
+
if config.get("point_loss", "uniform_cell_ce") != "uniform_cell_ce":
|
| 39 |
+
raise ValueError("Visual-token pointing requires uniform_cell_ce")
|
| 40 |
+
if config.get("point_decoder", "argmax") not in ("argmax", "components"):
|
| 41 |
+
raise ValueError("Unknown visual-token point decoder")
|
| 42 |
+
if not 0 < config.get("point_component_threshold", 0.1) <= 1:
|
| 43 |
+
raise ValueError("Invalid visual-token component threshold")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def image_token_candidates(ids, image_token_id, grid_thw, merge_size):
|
| 47 |
+
"""One original image only; reject video, missing tokens and ambiguous grids."""
|
| 48 |
+
t, h, w = [int(v) for v in grid_thw]
|
| 49 |
+
if merge_size < 1 or t != 1 or min(h, w) < 1 or h % merge_size or w % merge_size:
|
| 50 |
+
raise ValueError("Expected one spatially mergeable still-image grid")
|
| 51 |
+
shape = (h // merge_size, w // merge_size)
|
| 52 |
+
positions = [i for i, token in enumerate(ids) if token == image_token_id]
|
| 53 |
+
if len(positions) != math.prod(shape) or positions != list(range(positions[0], positions[-1] + 1)):
|
| 54 |
+
raise ValueError("Image-token positions do not match the merged visual grid")
|
| 55 |
+
return positions, shape
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def target_cells(points, shape):
|
| 59 |
+
"""Map normalized training labels to occupied cells, without modifying inputs."""
|
| 60 |
+
if not isinstance(points, (list, tuple)) or not points:
|
| 61 |
+
raise ValueError("Point supervision must contain at least one normalized point")
|
| 62 |
+
height, width = shape
|
| 63 |
+
if min(height, width) < 1:
|
| 64 |
+
raise ValueError("Empty visual grid")
|
| 65 |
+
cells = set()
|
| 66 |
+
for point in points:
|
| 67 |
+
if (not isinstance(point, (list, tuple)) or len(point) != 2
|
| 68 |
+
or any(isinstance(v, bool) or not isinstance(v, (int, float))
|
| 69 |
+
or not math.isfinite(v) or not 0 <= v <= 1 for v in point)):
|
| 70 |
+
raise ValueError("Point supervision must use finite normalized coordinates")
|
| 71 |
+
x, y = point
|
| 72 |
+
col, row = min(int(x * width), width - 1), min(int(y * height), height - 1)
|
| 73 |
+
cells.add(row * width + col)
|
| 74 |
+
return sorted(cells)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def decision_losses(logits, packed):
|
| 78 |
+
"""Ordinary CE unchanged; multiple occupied visual cells share one QA's mass.
|
| 79 |
+
|
| 80 |
+
Sparse targets avoid 0 * -inf on padded candidate slots. Coordinates in the
|
| 81 |
+
same visual cell are intentionally indistinguishable at this resolution.
|
| 82 |
+
"""
|
| 83 |
+
targets = packed["targets"].to(logits.device)
|
| 84 |
+
if "visual_target_owner" not in packed:
|
| 85 |
+
return F.cross_entropy(logits, targets, reduction="none")
|
| 86 |
+
log_probs = F.log_softmax(logits.float(), dim=-1)
|
| 87 |
+
losses = -log_probs.gather(1, targets[:, None]).squeeze(1)
|
| 88 |
+
owners = packed["visual_target_owner"].to(logits.device)
|
| 89 |
+
slots = packed["visual_target_slot"].to(logits.device)
|
| 90 |
+
mass = packed["visual_target_mass"].to(logits.device)
|
| 91 |
+
visual_losses = torch.zeros_like(losses).index_add(0, owners, -log_probs[owners, slots] * mass)
|
| 92 |
+
selected = owners.unique()
|
| 93 |
+
return losses.index_copy(0, selected, visual_losses[selected])
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def decode_visual_points(logits, shape, decoder="argmax", relative_threshold=0.1):
|
| 97 |
+
"""Decode only model scores and known geometry; never receive GT/count/masks."""
|
| 98 |
+
values = np.asarray(logits, dtype=np.float64)
|
| 99 |
+
height, width = shape
|
| 100 |
+
if min(height, width) < 1 or values.shape != (height * width,) or not np.isfinite(values).all():
|
| 101 |
+
raise ValueError("Expected one finite score per merged visual token")
|
| 102 |
+
probabilities = np.exp(values - values.max())
|
| 103 |
+
probabilities /= probabilities.sum()
|
| 104 |
+
grid = probabilities.reshape(height, width)
|
| 105 |
+
if decoder == "argmax":
|
| 106 |
+
index = int(values.argmax())
|
| 107 |
+
y, x = divmod(index, width)
|
| 108 |
+
points = [[(x + 0.5) / width, (y + 0.5) / height]]
|
| 109 |
+
elif decoder == "components":
|
| 110 |
+
components = component_points(grid, relative_threshold=relative_threshold, connectivity=8)["components"]
|
| 111 |
+
components.sort(key=lambda c: (-c["peak_probability"], *c["peak_cell"]))
|
| 112 |
+
points = [[(c["peak_cell"][1] + 0.5) / width, (c["peak_cell"][0] + 0.5) / height]
|
| 113 |
+
for c in components]
|
| 114 |
+
else:
|
| 115 |
+
raise ValueError(f"Unknown visual-token point decoder: {decoder}")
|
| 116 |
+
return {"prediction": points[0], "points": points, "grid_hw": list(shape),
|
| 117 |
+
"probabilities": grid.tolist(), "pointing_method": VISUAL_TOKENS,
|
| 118 |
+
"point_decoder": decoder, "point_component_threshold": relative_threshold if decoder == "components" else None}
|