Arc Tagger v0.1.0: AT10Mv2 step88000 + refit thresholds + arctag package
Browse files- LICENSE-arc_audio +201 -0
- MANIFEST.sha256 +38 -0
- README.md +194 -0
- arctag/__init__.py +110 -0
- arctag/_core/__init__.py +1 -0
- arctag/_core/arc_audio/__init__.py +2 -0
- arctag/_core/arc_audio/mamba3/__init__.py +5 -0
- arctag/_core/arc_audio/mamba3/layer.py +332 -0
- arctag/_core/arc_audio/mamba3/scan.py +195 -0
- arctag/_core/arc_audio/mamba3/ssd.py +106 -0
- arctag/_core/arc_audio/model.py +363 -0
- arctag/_core/arc_audio/ontology.py +79 -0
- arctag/_core/arc_audio/wordch.py +332 -0
- arctag/_core/arc_text.py +496 -0
- arctag/_core/engine.py +888 -0
- arctag/_core/fast_arc.py +575 -0
- arctag/_core/kb.py +290 -0
- arctag/_core/kb_strfeat.py +37 -0
- arctag/_core/losses.py +254 -0
- arctag/_core/speaker_link.py +288 -0
LICENSE-arc_audio
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://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 |
+
http://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.
|
MANIFEST.sha256
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
432e1c0db00dca1695c84c2e0b05f9b1f6f23a45acbc1bc4173a2669f9fef9a2 ./arctag/_core/arc_audio/__init__.py
|
| 2 |
+
fabe47fdbed606eb9bb17cd1f8cb809d4b0e765b3bc45c8aa0b7ad30f14a6026 ./arctag/_core/arc_audio/mamba3/__init__.py
|
| 3 |
+
640a5d529a810de1cddc0919b4e8744bfc5642257ce1abf6e4e65d74a54fc7c2 ./arctag/_core/arc_audio/mamba3/layer.py
|
| 4 |
+
96fb2a6fba2b0aed4fed121a95bb9307adf319afdff9a897d93fc1007f897de4 ./arctag/_core/arc_audio/mamba3/scan.py
|
| 5 |
+
e5a5b7f94c059f01250a5e8af1063d7aaf98f338b8596048884d018485c65a90 ./arctag/_core/arc_audio/mamba3/ssd.py
|
| 6 |
+
d86b6d7b6ee1653bfd91dff4c4170b30244650ccf992cf8e8f67ea3ae9c9009f ./arctag/_core/arc_audio/model.py
|
| 7 |
+
365ae9423a5fe89484178cb2823ab594ce58fc85f4df85a1a2019bb1c3568724 ./arctag/_core/arc_audio/ontology.py
|
| 8 |
+
aced3f28bd6f821e2edf522f37c127a8370ca54f508a9ed05d6206447993bcd6 ./arctag/_core/arc_audio/wordch.py
|
| 9 |
+
99497a4f0ae7ed568f593c472b1edd1cf95fcd09a729c7ae666936bf868e13d1 ./arctag/_core/arc_text.py
|
| 10 |
+
427234a679197cd846d13e4e9b293d34492d06814aafbbd842c527fddc162450 ./arctag/_core/engine.py
|
| 11 |
+
abad828a99d8e961277fdc3ce3f9c8af9fa09b61289f4882dde257efef832e17 ./arctag/_core/fast_arc.py
|
| 12 |
+
02bd73a10018f86d788f8784cd12fd10e19836e95975362fbcb0d9e9dfc90381 ./arctag/_core/__init__.py
|
| 13 |
+
3d3fb0551279f43413801a2e03148121f960097cab72124132ebe6a8395d4f9f ./arctag/_core/kb.py
|
| 14 |
+
71352e7c576d76458431a24ee6df6ebaa95919f79fa3ad9ba66f1f7e37103b0d ./arctag/_core/kb_strfeat.py
|
| 15 |
+
0ff792d9926e12a99954a0f7a1705ae2dacb2f95827f9e5ba2e334f4755881d7 ./arctag/_core/losses.py
|
| 16 |
+
355e635e6935d60906ab7ee51b7921b28509bb99b4dccc89b69f9a450d3f584f ./arctag/_core/speaker_link.py
|
| 17 |
+
7799029e19b871b08949af23b2bd1c25da4c720e807fbdfa6c9776b95e43ebae ./arctag/_core/stream_arc.py
|
| 18 |
+
6804a7d18aa7faa6b632537e9478a95a03bfcc8c7625d044c40acea96c770284 ./arctag/_core/tagtable.py
|
| 19 |
+
552c4ace1562ba31a532a86afc0fe57f7c80ce95cc72b539e2ea6c5b2736c6b6 ./arctag/_core/v2.py
|
| 20 |
+
0b24e8f43145c878966f722ebfdc55e1e937c3005301d4d284306bc1eedc8f50 ./arctag/driver.py
|
| 21 |
+
09b3d76601a0756ef761628112f26886cff287113024605d34eef3d1936a4a01 ./arctag/__init__.py
|
| 22 |
+
505e9b93f1895cf4e29374c11041fe5316b7c44e226f63000a1945910c0d9109 ./config.json
|
| 23 |
+
d11cc9c244b81636f087e2165bfea9e7c3fb6d8c09a1975cb17ea3293f8d7370 ./demo_settings.json
|
| 24 |
+
d28c8d84e9d3829cbb2dd5b2d324812aeab248691d771d5b4b82cb9cd5ce3648 ./example.py
|
| 25 |
+
c45bd62ce519bcc479834b7d94396f1f29dee0b606aeff7e29adadacf6342383 ./label_norm.json
|
| 26 |
+
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./LICENSE-arc_audio
|
| 27 |
+
e80f2c903d3f087c045c0f9482718533c7cf2d9d42817cfea5943fad72935ed8 ./model.safetensors
|
| 28 |
+
503a2a7e125b7311b3198a1d8b4a37901377093c85b281def26100d2b6b56939 ./ontology/ext_cc_roles_E.npy
|
| 29 |
+
9040786b540fcbfbc9137abf81a05e972e9380ae0c208bb55fae9c459aa4b97f ./ontology/ext_cc_roles.json
|
| 30 |
+
05cc12d2bdafa7ef463797ca2997ecda2dd914eaf4718a86959f45f0a8840f96 ./ontology/head_E_r8_bge_small.npy
|
| 31 |
+
2a2d312fcea8a6082fe8035e1e35d44ff425ed26bb14a7841205bb6054c845ea ./ontology/tags_v1_r8.json
|
| 32 |
+
ecabaf702a9ad51efc739a44d6c5b5ecc92403c62d5ea365f1970cc466f2752a ./ontology/text_tags.json
|
| 33 |
+
90da9da1a2da0eac47fa6ea441b29179e7100ccbe222beb3d979257414a01e91 ./README.md
|
| 34 |
+
174b3daab120ee5ba78a21f811f732f923b40fdfa95b5b12fd6adcedd4ab2369 ./requirements.txt
|
| 35 |
+
20037d4fa059a7efe37acd4ba1c334a13c9c930c53ee116c0dcde185f21d5537 ./thresholds/events_step88000.json
|
| 36 |
+
9297c10a69e3b17a360c4b1f6f62050b247fc74ca28e3cd182804fdea9756300 ./thresholds/step88000.json
|
| 37 |
+
6dc720dd382f90cc514b18bbda9b1c8e083b0cba606bf3ab5129d98bc9389d3b ./tok/bpe8k/tokenizer_config.json
|
| 38 |
+
2ae4623fc283d9b25b83e9efb9bf376f24c8974a01a2bbbe6cf8b0b29ac6fc35 ./tok/bpe8k/tokenizer.json
|
README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- en
|
| 4 |
+
license: other
|
| 5 |
+
library_name: pytorch
|
| 6 |
+
pipeline_tag: token-classification
|
| 7 |
+
tags:
|
| 8 |
+
- streaming
|
| 9 |
+
- asr
|
| 10 |
+
- ner
|
| 11 |
+
- pii
|
| 12 |
+
- redaction
|
| 13 |
+
- intent
|
| 14 |
+
- dialogue-act
|
| 15 |
+
- mamba
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# Arc Tagger (10M), v0.1.0
|
| 19 |
+
|
| 20 |
+
Arc Tagger is a **text-only, streaming tagger** with about 9.8 M parameters. It reads the word stream of **any ASR** (Deepgram, Nemotron,
|
| 21 |
+
Whisper, our own ASR, or plain text). As each word arrives, it emits entity and number spans, PII spans for redaction, profanity and
|
| 22 |
+
toxicity, discourse acts, intents, safety flags, speaker roles, events (speaker change, turn yield, sports, legal and meeting events),
|
| 23 |
+
content state and emotion (experimental).
|
| 24 |
+
|
| 25 |
+
* Checkpoint: `AT10Mv2` step 88000. It scored best on all gold sets of its run.
|
| 26 |
+
* Architecture: a word channel (8k byte-level BPE, 2 causal local-attention layers) feeds a 6-layer Mamba-3 trunk. Global KV-memory
|
| 27 |
+
attention layers, domain FiLM and adapters condition the trunk. The read-outs are definition-keyed tag heads plus a v2 span head (BIO
|
| 28 |
+
and type per family), a span verifier and pooled utterance, speaker and item heads. The model is causal and runs one recurrent step
|
| 29 |
+
per word, so a revision re-runs only the revised words.
|
| 30 |
+
* Runs on CPU. Measured on one core (busy shared box, 1 thread): **engine about 16 ms per word p50, 25 ms p95**. A full
|
| 31 |
+
`push_words()` call, including span decoding and read-outs, takes about 24 ms p50 and 45 ms p95.
|
| 32 |
+
|
| 33 |
+
## Tag families
|
| 34 |
+
|
| 35 |
+
276 tags across 10 read-outs. Every tag name comes from the Whissle ontology r8 plus the extension `ext_cc_roles1`.
|
| 36 |
+
|
| 37 |
+
| family | tags (examples) | read-out |
|
| 38 |
+
|---|---|---|
|
| 39 |
+
| Entities | `entity.mention.{person,organization,team,place,event_name,legal_ref,term,...}` (10) | word spans |
|
| 40 |
+
| Numbers | `entity.number.{date,time_of_day,duration,count,money,percent,measure,score,clock,ordinal,id_pii,...}` (16), `entity.numeral` | word spans |
|
| 41 |
+
| PII / redaction | `safety.pii.{private_name,phone_number,email,street_address,card_account_number,gov_id,date_of_birth,url_handle,public_contact}` spans; `safety.pii.{request,leak}` per utterance | spans and utterance |
|
| 42 |
+
| Profanity / toxicity | `safety.profanity.{mild,strong}`, `safety.toxicity.*` (6) | spans and utterance |
|
| 43 |
+
| Safety | `safety.scam.*` (6), `safety.crisis.self_harm`, `safety.threat.escalation`, `safety.child.*`, `safety.solicitation.dating_romantic` | utterance |
|
| 44 |
+
| Discourse act | `discourse.act.*` (statement, question, command_request, ...), `discourse.answer`, `discourse.backchannel`, `discourse.assistant.*` | utterance |
|
| 45 |
+
| Intent | 26 cross-domain `intent.*` (request_information, schedule_arrange, complain_criticize, ...), 60 assistant `discourse.intent.*` (alarm_set, weather_query, ...) | utterance |
|
| 46 |
+
| Speaker role | `speaker.role.*` (19, including contact-centre agent and customer) | per speaker (needs `spk` ids on the words) |
|
| 47 |
+
| Events | `speaker.change`, `discourse.turn_yield`, `discourse.interruption`, `event.sports.*` (18), `event.legal.*`, `event.meeting.{decision,action_item}` | per word |
|
| 48 |
+
| Content state | `content.mode`, `content.subject`, `content.game_state`, `content.legal_phase`, `content.meeting_phase` | per word (frame) |
|
| 49 |
+
| Emotion (experimental) | `affect.emotion.*` (5), `affect.arousal.*` (3) | utterance; low support, mostly "neutral" |
|
| 50 |
+
|
| 51 |
+
`ontology/` holds the full definitions. `config.json["columns"]` lists every `tag@readout` column.
|
| 52 |
+
|
| 53 |
+
## Quickstart
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
pip install huggingface_hub
|
| 57 |
+
huggingface-cli download WhissleAI/arc-tagger --local-dir arc-tagger # private repo: huggingface-cli login first
|
| 58 |
+
cd arc-tagger
|
| 59 |
+
pip install -r requirements.txt # CPU torch is fine: pip install torch --index-url https://download.pytorch.org/whl/cpu
|
| 60 |
+
python example.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
```python
|
| 64 |
+
import sys; sys.path.insert(0, "arc-tagger") # the package ships in the repo (arctag/)
|
| 65 |
+
from arctag import ArcTagger
|
| 66 |
+
|
| 67 |
+
tg = ArcTagger.from_pretrained("arc-tagger") # a local dir, or a repo id: ArcTagger.from_pretrained("WhissleAI/arc-tagger")
|
| 68 |
+
out = tg.tag("Hi, this is Maria from Northwind Bank. Can I get your phone number?", domain="talk")
|
| 69 |
+
print(out["redacted_text"]); print(out["spans"]); print(out["utterances"])
|
| 70 |
+
|
| 71 |
+
# streaming (words as the ASR emits them; interim hypotheses can be revised)
|
| 72 |
+
st = tg.stream(domain="assistant")
|
| 73 |
+
st.push_words(["set", "an", "alarm", "for"], final=False) # interim
|
| 74 |
+
up = st.push_words(["set", "an", "alarm", "for", "seven", "thirty."], revise_from=0, final=True)
|
| 75 |
+
up = st.endpoint() # end of utterance -> utterance read-outs in up["utterances"]
|
| 76 |
+
res = st.finalize() # full result (same schema as tag())
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### Input
|
| 80 |
+
|
| 81 |
+
`tag(x, domain="auto", tier="T0")`
|
| 82 |
+
* `x` can be a `str`, a list of words, or a list of word dicts `{"w": str, "t0": s, "t1": s, "conf": float, "spk": str|int, "final": bool}`.
|
| 83 |
+
Every field except `w` is optional.
|
| 84 |
+
* Utterances end after words ending in `. ? !`. In streaming, call `endpoint()` at the ASR's end of utterance or end of turn.
|
| 85 |
+
|
| 86 |
+
`domain` is one of `auto | talk | meeting | court | assistant | sports | news`.
|
| 87 |
+
* It conditions the model through domain FiLM and adapters, and picks the per-domain thresholds.
|
| 88 |
+
* `talk` covers contact centre, podcasts and chat. Inside `sports` and `court`, the specific league or court type is detected from
|
| 89 |
+
lexicon cues.
|
| 90 |
+
* `auto` runs the "unknown" domain, which is weaker. **Set the domain when you know it.**
|
| 91 |
+
|
| 92 |
+
`tier`
|
| 93 |
+
* `T0` (default; 0 words of lookahead; the thresholds were fitted on it)
|
| 94 |
+
* `T1` (at most 3 words of delay)
|
| 95 |
+
* `T2` (utterance end)
|
| 96 |
+
|
| 97 |
+
Streaming: `push_words(words, final=True, revise_from=None, endpoint=False)`
|
| 98 |
+
* `revise_from=k` replaces `words[k:]`. Use it for interim hypotheses such as Deepgram's `is_final=false` or a growing RNNT hypothesis.
|
| 99 |
+
|
| 100 |
+
### Output (JSON)
|
| 101 |
+
|
| 102 |
+
```jsonc
|
| 103 |
+
{
|
| 104 |
+
"meta": {"model": "arc-tagger", "version": "0.1.0", "step": 88000, "domain": "talk", "fine_domain": "talk", "tier": "T0", "thresholds": "..."},
|
| 105 |
+
"words": ["Hi,", "this", "is", "Maria", ...],
|
| 106 |
+
"spans": [ // all decoded spans (word indices [start, end))
|
| 107 |
+
{"tag": "safety.pii.private_name", "family": "pii", "start": 3, "end": 4, "text": "Maria", "p": 0.97, "thr": 0.42,
|
| 108 |
+
"alt": {"tag": "...", "p": 0.01}, // runner-up type
|
| 109 |
+
"ver": 0.99, // span verifier probability
|
| 110 |
+
"src": "head"} // decoded by the v2 span head
|
| 111 |
+
],
|
| 112 |
+
"entities": [...], "pii": [...], "profanity_toxicity": [...], // spans split by family
|
| 113 |
+
"redacted_text": "Hi, this is [NAME] from ...", // PII / profanity / toxicity spans masked (public_contact is not masked)
|
| 114 |
+
"utterances": [
|
| 115 |
+
{"start": 0, "end": 8, "text": "...",
|
| 116 |
+
"intent": ["intent.request_information"], "act": ["discourse.act.question"], "safety": ["safety.pii.request"],
|
| 117 |
+
"role": [], "role_note": "speaker role needs diarization (no speaker id on words)",
|
| 118 |
+
"item": [], "state": {"content.mode": ["content.mode.call_in"]},
|
| 119 |
+
"emotion": [{"tag": "affect.emotion.neutral", "p": 0.93, "thr": 0.6, "on": true}, ...],
|
| 120 |
+
"scores": {"intent": [{"tag": ..., "p": ..., "thr": ..., "on": ...}], "act": [...], ...}} // top-k with probabilities
|
| 121 |
+
],
|
| 122 |
+
"events": [{"tag": "speaker.change", "at": 15, "word": "Yeah,", "p": 0.94, "thr": 0.3, "tier": "T0", "read": "ev_rec"}]
|
| 123 |
+
}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
The label lists (`intent`, `act`, `safety`, ...) contain the tags at or above their threshold (`on`). `scores` keeps the top-k with
|
| 127 |
+
their probabilities.
|
| 128 |
+
|
| 129 |
+
Each streaming update (`push_words` and `endpoint`) returns:
|
| 130 |
+
* `n_words` and `changed_from`
|
| 131 |
+
* `spans`: the re-decoded window; replace your spans that end after its start
|
| 132 |
+
* `events` (new ones only)
|
| 133 |
+
* `utterances` (the ones that just ended)
|
| 134 |
+
* `running`: the live intent, act, safety and state of the open utterance
|
| 135 |
+
|
| 136 |
+
## Latency tiers
|
| 137 |
+
|
| 138 |
+
| tier | lookahead | use |
|
| 139 |
+
|---|---|---|
|
| 140 |
+
| T0 | 0 words | live display and zero-lag redaction (default; thresholds fitted here) |
|
| 141 |
+
| T1 | up to 3 words | revisions a few words later |
|
| 142 |
+
| T2 | to utterance end (up to 8 words) | final utterance labels |
|
| 143 |
+
|
| 144 |
+
All three tiers are delayed read-outs of one causal pass, so they add no extra compute. Utterance-level labels (intent, act, safety,
|
| 145 |
+
role) are read at the endpoint.
|
| 146 |
+
|
| 147 |
+
## Evaluation
|
| 148 |
+
|
| 149 |
+
Internal multi-domain dev and test sets cover contact centre, assistant, meetings, news, talk, sports and courts, using real and
|
| 150 |
+
simulated ASR (Deepgram and RNNT word streams).
|
| 151 |
+
|
| 152 |
+
| metric | old gold | cleaned gold |
|
| 153 |
+
|---|---|---|
|
| 154 |
+
| light eval, primary score (step 88000) | 0.693 | 0.764 |
|
| 155 |
+
| test, T0 macro AP | 0.719 | 0.793 |
|
| 156 |
+
|
| 157 |
+
**Caveat:** "cleaned gold" re-adjudicated the eval labels after a confident-learning label audit. The model selected the suspect
|
| 158 |
+
labels and Claude adjudicated them. The same audit also cleaned part of the training data, so cleaned-gold numbers are partly
|
| 159 |
+
favourable to this model family. Treat **old-gold numbers as the conservative estimate**. These are internal benchmarks, not public
|
| 160 |
+
leaderboards.
|
| 161 |
+
|
| 162 |
+
## Files
|
| 163 |
+
|
| 164 |
+
| file | what |
|
| 165 |
+
|---|---|
|
| 166 |
+
| `model.safetensors` | weights (fp32, 45 MB) |
|
| 167 |
+
| `config.json` | model, ontology and read-out columns; training args with data paths removed |
|
| 168 |
+
| `tok/bpe8k/` | 8k byte-level BPE tokenizer (words are fed as `" " + word`) |
|
| 169 |
+
| `ontology/` | r8 ontology, text-tag table, `ext_cc_roles` extension, frozen tag-definition embeddings (bge-small) |
|
| 170 |
+
| `thresholds/step88000.json` | display thresholds: a precision-first T0 refit on the cleaned dev split, per tag, read-out and domain |
|
| 171 |
+
| `thresholds/events_step88000.json` | display tier, thresholds and hidden list for each event |
|
| 172 |
+
| `demo_settings.json` | the live demo's engine floors and settings (span threshold of at least .3; toxicity of at least .5 outside safety verticals) |
|
| 173 |
+
| `label_norm.json` | label-normalisation conventions behind the tags (training-time; pii1sn3) |
|
| 174 |
+
| `arctag/` | Python package: `ArcTagger`, `driver.py` (session to JSON), `_core/` (the demo's streaming engine and model code, vendored with only file paths changed) |
|
| 175 |
+
| `onnx/` | ONNX export for the C API, if present (published separately) |
|
| 176 |
+
| `MANIFEST.sha256` | checksums |
|
| 177 |
+
|
| 178 |
+
## Limitations
|
| 179 |
+
|
| 180 |
+
* **English only.** Non-English input gives garbage-like output.
|
| 181 |
+
* Text only: no audio cues. The model cannot detect speaker identity, emotion from voice or vocal events. **Speaker roles need speaker
|
| 182 |
+
ids** (`spk`) on the words, for example from ASR diarization.
|
| 183 |
+
* Utterance-level safety and scam flags can fire on short or out-of-domain utterances; use `scores` (p) and your own policy for
|
| 184 |
+
anything consequential. Emotion is low-support and mostly predicts neutral.
|
| 185 |
+
* The `auto` domain is weaker than setting the domain. Some sports events fire loosely outside sports.
|
| 186 |
+
* Redaction here masks the decoded spans after the fact. The live demo's zero-lag redactor (carrier-phrase pre-arm, regex rules, hold
|
| 187 |
+
modes) is not included.
|
| 188 |
+
* Thresholds are fitted for precision first on internal dev data. Refit them for your traffic if you can.
|
| 189 |
+
* CPU inference only in this package, which is fp32. The ONNX export, if present, is the path for C and embedded use.
|
| 190 |
+
|
| 191 |
+
## Licence
|
| 192 |
+
|
| 193 |
+
Weights: WhissleAI, private release (`license: other`). The vendored `arctag/_core/arc_audio` subset is from WhissleAI Arc 1.4.2,
|
| 194 |
+
licensed under Apache-2.0 (see `LICENSE-arc_audio`).
|
arctag/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc Tagger: text-only streaming tagger (~9.8 M params) for ASR word streams.
|
| 2 |
+
|
| 3 |
+
from arctag import ArcTagger
|
| 4 |
+
tg = ArcTagger.from_pretrained(".") # or a Hugging Face repo id
|
| 5 |
+
out = tg.tag("Hi, this is Dana from Acme. Can you move my appointment to Friday?")
|
| 6 |
+
|
| 7 |
+
st = tg.stream(domain="talk") # streaming: words in as the ASR emits them
|
| 8 |
+
st.push_words(["hi", "this", "is"], final=False) # interim
|
| 9 |
+
st.push_words(["hi,", "this", "is", "Dana."], revise_from=0, final=True)
|
| 10 |
+
st.endpoint()
|
| 11 |
+
res = st.finalize()
|
| 12 |
+
|
| 13 |
+
CPU only. The model code is the demo's streaming engine (arctag/_core, vendored unchanged except for file paths).
|
| 14 |
+
"""
|
| 15 |
+
import importlib.util, json, os, sys
|
| 16 |
+
|
| 17 |
+
__version__ = "0.1.0"
|
| 18 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 19 |
+
_CORE = os.path.join(_HERE, "_core")
|
| 20 |
+
_EN = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _engine(home):
|
| 24 |
+
"""import the vendored engine once (module-level path constants resolve against `home`)"""
|
| 25 |
+
global _EN
|
| 26 |
+
if _EN is None:
|
| 27 |
+
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
|
| 28 |
+
os.environ.setdefault("ARCTAG_HOME", home)
|
| 29 |
+
os.environ["ARCTAG_ROLE"] = "offline" # engine.py: no prod checkpoint-dir guard
|
| 30 |
+
if _CORE not in sys.path:
|
| 31 |
+
sys.path.insert(0, _CORE)
|
| 32 |
+
spec = importlib.util.spec_from_file_location("arctag_engine", os.path.join(_CORE, "engine.py"))
|
| 33 |
+
_EN = importlib.util.module_from_spec(spec); sys.modules["arctag_engine"] = _EN
|
| 34 |
+
spec.loader.exec_module(_EN)
|
| 35 |
+
return _EN
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _driver():
|
| 39 |
+
from . import driver
|
| 40 |
+
return driver
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ArcTagger:
|
| 44 |
+
def __init__(self, home, EN, loaded, cfg):
|
| 45 |
+
self.home, self.EN, self.loaded, self.config = home, EN, loaded, cfg
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def from_pretrained(cls, path=".", revision=None, thresholds="refit"):
|
| 49 |
+
"""path: release dir (model.safetensors, config.json, tok/, ontology/, thresholds/) or a Hugging Face repo id.
|
| 50 |
+
thresholds: "refit" (default: thresholds/step88000.json + events_step88000.json, precision-first T0 refit on cleaned dev)
|
| 51 |
+
or "none" (0.5 everywhere, engine display floors still apply)."""
|
| 52 |
+
if not os.path.isdir(path):
|
| 53 |
+
from huggingface_hub import snapshot_download
|
| 54 |
+
path = snapshot_download(path, revision=revision, allow_patterns=["*.json", "*.safetensors", "*.npy", "*.py", "tok/*",
|
| 55 |
+
"ontology/*", "thresholds/*", "arctag/*"])
|
| 56 |
+
home = os.path.abspath(path)
|
| 57 |
+
EN = _engine(home)
|
| 58 |
+
AT = sys.modules["arc_text"]; TT = sys.modules["tagtable"]
|
| 59 |
+
AT.BPE8K = os.path.join(home, "tok", "bpe8k", "tokenizer.json")
|
| 60 |
+
cfg = json.load(open(os.path.join(home, "config.json")))
|
| 61 |
+
ext = os.path.join(home, cfg.get("onto_ext") or "ontology/ext_cc_roles.json")
|
| 62 |
+
T = TT.TextTable(r8=os.path.join(home, "ontology", "tags_v1_r8.json"),
|
| 63 |
+
text_tags=os.path.join(home, "ontology", "text_tags.json"), ext=ext)
|
| 64 |
+
T.load_E(os.path.join(home, "ontology", "head_E_r8_bge_small.npy"))
|
| 65 |
+
if T.sha != cfg.get("ontology_sha"):
|
| 66 |
+
raise RuntimeError(f"ontology sha {T.sha} != config {cfg.get('ontology_sha')}")
|
| 67 |
+
loaded = _load_model(EN, home, cfg, T, thresholds)
|
| 68 |
+
return cls(home, EN, loaded, cfg)
|
| 69 |
+
|
| 70 |
+
def stream(self, domain="auto", tier="T0"):
|
| 71 |
+
"""a streaming session (driver.Stream): push_words(words, final=True, revise_from=None, endpoint=False), endpoint(),
|
| 72 |
+
finalize() -> result dict"""
|
| 73 |
+
return _driver().Stream(self.EN, self.loaded, domain=domain, tier=tier, meta=self._meta())
|
| 74 |
+
|
| 75 |
+
def tag(self, x, domain="auto", tier="T0"):
|
| 76 |
+
"""x: text (str), a list of words (str) or word dicts {"w", "t0", "t1", "conf", "spk", "final"}. Utterances end after
|
| 77 |
+
words ending in . ? ! Returns the structured result (see README "Output")."""
|
| 78 |
+
return _driver().tag(self.EN, self.loaded, x, domain=domain, tier=tier, meta=self._meta())
|
| 79 |
+
|
| 80 |
+
def _meta(self):
|
| 81 |
+
return {"model": "arc-tagger", "version": __version__, "step": self.loaded.step}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _load_model(EN, home, cfg, T, thresholds="refit"):
|
| 85 |
+
"""= demo/engine.py ModelManager._load + _thresholds for a single release dir"""
|
| 86 |
+
from safetensors.torch import load_file
|
| 87 |
+
AT = sys.modules["arc_text"]; SA = sys.modules["stream_arc"]
|
| 88 |
+
step = int(cfg.get("step", 0))
|
| 89 |
+
m = AT.ArcText(T, scratch=cfg.get("scratch") or AT.SCRATCH_10M)
|
| 90 |
+
sd = load_file(os.path.join(home, "model.safetensors"))
|
| 91 |
+
SA.add_modules_for(m, sd)
|
| 92 |
+
missing, unexpected = m.load_state_dict(sd, strict=False)
|
| 93 |
+
if unexpected:
|
| 94 |
+
raise RuntimeError(f"checkpoint keys without a module: {unexpected[:6]}")
|
| 95 |
+
bad = [k for k in missing if not k.startswith(("head.E",))]
|
| 96 |
+
if bad:
|
| 97 |
+
import warnings
|
| 98 |
+
warnings.warn(f"{len(bad)} missing keys (e.g. {bad[:3]})")
|
| 99 |
+
m.eval()
|
| 100 |
+
own = cfg.get("thresholds") or {}
|
| 101 |
+
pref, pstep, ev, evstep = {}, None, {}, None
|
| 102 |
+
if thresholds == "refit":
|
| 103 |
+
d = os.path.join(home, "thresholds")
|
| 104 |
+
pref, pstep = EN.load_prod_thresholds(step, d)
|
| 105 |
+
ev, evstep = EN.load_event_tiers(step, d)
|
| 106 |
+
elif thresholds != "none":
|
| 107 |
+
raise ValueError("thresholds must be 'refit' or 'none'")
|
| 108 |
+
thr = EN.Thresholds(own, {}, None, pref=pref, pref_step=pstep)
|
| 109 |
+
thr.set_events(ev, evstep)
|
| 110 |
+
return EN.Loaded(step, home, m, T, thr, cfg)
|
arctag/_core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# vendored engine modules (copies of the Arc Tagger demo/code modules; see arctag/__init__.py)
|
arctag/_core/arc_audio/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Subset of arc_audio 1.4.2 (WhissleAI Arc, Apache-2.0, see LICENSE-arc_audio) needed by the Arc Tagger: model.py (Trunk / TagHead /
|
| 2 |
+
EntityHeads), wordch.py (word channel), ontology.py, mamba3/ (pure-PyTorch Mamba-3). Files copied unchanged; the audio API is not included."""
|
arctag/_core/arc_audio/mamba3/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-PyTorch Mamba-3 layer (Lahoti et al., arXiv 2603.15569; WhissleAI in-house implementation, no CUDA kernels):
|
| 2 |
+
layer.py (recurrent path + step()), scan.py (chunked scan), ssd.py (chunked-SSD parallel path used for offline inference).
|
| 3 |
+
Identical to the copy the model was trained with."""
|
| 4 |
+
from .layer import Mamba3Layer # noqa: F401
|
| 5 |
+
from .ssd import mamba3_layer_ssd, _f # noqa: F401
|
arctag/_core/arc_audio/mamba3/layer.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mamba-3 (Lahoti et al., arXiv 2603.15569), implemented to the paper.
|
| 2 |
+
|
| 3 |
+
THREE CHANGES FROM MAMBA-2, implemented here in the order the paper derives
|
| 4 |
+
them. Nothing is improvised: where this file departs from the paper it says
|
| 5 |
+
so, and the equivalence tests at the bottom are how that claim is checked.
|
| 6 |
+
|
| 7 |
+
1. EXPONENTIAL-TRAPEZOIDAL DISCRETIZATION (Sec 3.1). Mamba-1/2 approximate
|
| 8 |
+
|
| 9 |
+
integral_{t_{k-1}}^{t_k} e^{A(t_k - tau)} B(tau) x(tau) d tau
|
| 10 |
+
|
| 11 |
+
by holding the endpoint (Euler). Mamba-3 averages both endpoints
|
| 12 |
+
(trapezoidal), which makes the recurrence depend on x_{t-1} as well as
|
| 13 |
+
x_t:
|
| 14 |
+
|
| 15 |
+
h_t = alpha_t h_{t-1} + beta_t B_{t-1} x_{t-1} + gamma_t B_t x_t
|
| 16 |
+
|
| 17 |
+
with alpha_t = exp(Delta_t A), beta_t = (Delta_t/2) alpha_t,
|
| 18 |
+
gamma_t = Delta_t/2. That two-tap dependence IS the implicit convolution
|
| 19 |
+
the paper describes - it is why Mamba-3 can drop the short causal conv
|
| 20 |
+
that Mamba-1/2 carry as a separate component (Sec 3.1, and eq 7's
|
| 21 |
+
factorisation of L into a 1-semiseparable matrix times a 2-band matrix).
|
| 22 |
+
|
| 23 |
+
2. COMPLEX STATE via the RoPE trick (Sec 3.2, Prop 2 and 3). A complex SSM
|
| 24 |
+
of state N/2 is equivalent to a REAL SSM of state N whose transition is a
|
| 25 |
+
scalar decay times a block-diagonal of 2x2 rotations:
|
| 26 |
+
|
| 27 |
+
R_t = blockdiag({ R(Delta_t theta_t[i]) })
|
| 28 |
+
h_t = alpha_t R_t h_{t-1} + ...
|
| 29 |
+
|
| 30 |
+
Real non-negative eigenvalues cannot express rotation, which is why
|
| 31 |
+
Mamba-2 provably fails state tracking (parity). This file implements the
|
| 32 |
+
real-equivalent form, not complex arithmetic, exactly as Prop 2 licenses.
|
| 33 |
+
|
| 34 |
+
3. MIMO (Sec 3.3). x_t: (P,) -> (P,R), B_t: (N,) -> (N,R), C_t likewise;
|
| 35 |
+
the state stays (N,P) and the update becomes a matmul B_t x_t^T rather
|
| 36 |
+
than an outer product. Memory traffic is dominated by h_t and barely
|
| 37 |
+
moves; FLOPs rise by R. Arithmetic intensity goes Theta(1) -> Theta(R),
|
| 38 |
+
which is the whole point during memory-bound decode.
|
| 39 |
+
|
| 40 |
+
WHAT THIS FILE DOES NOT DO. No fused CUDA scan - the paper's speed comes
|
| 41 |
+
from hardware-aware kernels and this runs on MPS/CPU with an explicit loop
|
| 42 |
+
for the recurrent path and a materialised mask for the parallel path. The
|
| 43 |
+
MODELLING claims are reproducible here; the LATENCY claims are not.
|
| 44 |
+
"""
|
| 45 |
+
from __future__ import annotations
|
| 46 |
+
|
| 47 |
+
import math
|
| 48 |
+
|
| 49 |
+
import torch
|
| 50 |
+
import torch.nn as nn
|
| 51 |
+
import torch.nn.functional as F
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def rotate_pairs(h: torch.Tensor, ang: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
"""Apply blockdiag 2x2 rotations to the state's (even, odd) channel pairs.
|
| 56 |
+
|
| 57 |
+
h: (..., N) state, N even
|
| 58 |
+
ang: (..., N/2) rotation angle per pair
|
| 59 |
+
"""
|
| 60 |
+
a, b = h[..., 0::2], h[..., 1::2]
|
| 61 |
+
c, s = torch.cos(ang), torch.sin(ang)
|
| 62 |
+
out = torch.empty_like(h)
|
| 63 |
+
out[..., 0::2] = c * a - s * b
|
| 64 |
+
out[..., 1::2] = s * a + c * b
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ssm_scan(alpha, beta, gamma, theta, S, Cm, P, state=None, prev_S=None):
|
| 69 |
+
"""The Mamba-3 recurrence (paper eq 11), shared by Mamba3Layer and MMSE.
|
| 70 |
+
|
| 71 |
+
This is the SINGLE SOURCE OF TRUTH for the recurrence - both the single-
|
| 72 |
+
stream layer and the multi-stream MMSE core call it, so a paper-faithfulness
|
| 73 |
+
fix here (e.g. the beta-term rotation) applies to both and cannot drift.
|
| 74 |
+
|
| 75 |
+
h_t = R_t(alpha_t h_{t-1} + beta_t S_{t-1}) + gamma_t S_t
|
| 76 |
+
y_t = C_t^T h_t
|
| 77 |
+
|
| 78 |
+
alpha,beta,gamma: (B,T,H) per-step scalars
|
| 79 |
+
theta: (B,T,H,N/2) per-step rotation angles (already * dt)
|
| 80 |
+
S: (B,T,H,N,P) per-step write B_t x_t^T (or summed streams)
|
| 81 |
+
Cm: (B,T,H,N,R) readout
|
| 82 |
+
P: head dim; state/prev_S carry across chunks for streaming.
|
| 83 |
+
Returns (y_flat (B,T,H*R*P), final state h, last write S[:,-1]).
|
| 84 |
+
"""
|
| 85 |
+
B, T, H, N, _ = S.shape
|
| 86 |
+
R = Cm.shape[-1]
|
| 87 |
+
h = torch.zeros(B, H, N, P, device=S.device, dtype=S.dtype) \
|
| 88 |
+
if state is None else state
|
| 89 |
+
ys = []
|
| 90 |
+
for t in range(T):
|
| 91 |
+
prev = S[:, t - 1] if t > 0 else prev_S
|
| 92 |
+
pre = alpha[:, t, :, None, None] * h
|
| 93 |
+
if prev is not None:
|
| 94 |
+
pre = pre + beta[:, t, :, None, None] * prev
|
| 95 |
+
h = rotate_pairs(pre.transpose(-1, -2), theta[:, t].unsqueeze(-2)
|
| 96 |
+
.expand(-1, -1, P, -1)).transpose(-1, -2)
|
| 97 |
+
h = h + gamma[:, t, :, None, None] * S[:, t]
|
| 98 |
+
ys.append((Cm[:, t].transpose(-1, -2) @ h).reshape(B, H * R * P))
|
| 99 |
+
return torch.stack(ys, 1), h, S[:, -1]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class Mamba3Layer(nn.Module):
|
| 103 |
+
"""One Mamba-3 layer. Recurrent and parallel paths, provably equal.
|
| 104 |
+
|
| 105 |
+
d_model input/output width
|
| 106 |
+
d_state N, the SSM state size (even; pairs are rotated)
|
| 107 |
+
rank R of the MIMO lift. rank=1 is SISO.
|
| 108 |
+
head_dim P, channels sharing one (alpha, Delta, B) recurrence.
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
def __init__(self, d_model: int, d_state: int = 64, rank: int = 1,
|
| 112 |
+
head_dim: int = 32, dt_min: float = 1e-3, dt_max: float = 0.5):
|
| 113 |
+
super().__init__()
|
| 114 |
+
assert d_state % 2 == 0, "d_state must be even: the state rotates in pairs"
|
| 115 |
+
assert d_model % head_dim == 0, "d_model must divide into heads of head_dim"
|
| 116 |
+
self.d_model, self.N, self.R, self.P = d_model, d_state, rank, head_dim
|
| 117 |
+
self.H = d_model // head_dim
|
| 118 |
+
|
| 119 |
+
# Gate branch (Mamba-2/3 block, Fig 2): the SSM output is multiplied by
|
| 120 |
+
# SiLU(in_proj(input)) before out_proj. Dimension d_model*R to match the
|
| 121 |
+
# pre-out_proj SSM output (H*R*P). Was defined-but-unused before.
|
| 122 |
+
self.in_proj = nn.Linear(d_model, d_model * rank)
|
| 123 |
+
self.B_proj = nn.Linear(d_model, self.H * d_state * rank)
|
| 124 |
+
self.C_proj = nn.Linear(d_model, self.H * d_state * rank)
|
| 125 |
+
self.x_proj = nn.Linear(d_model, d_model * rank)
|
| 126 |
+
self.dt_proj = nn.Linear(d_model, self.H)
|
| 127 |
+
self.theta_proj = nn.Linear(d_model, self.H * (d_state // 2))
|
| 128 |
+
# lambda_t: the DATA-DEPENDENT trapezoidal weight (paper Prop 1, Remark
|
| 129 |
+
# 3). gamma_t = lambda_t * dt, beta_t = (1-lambda_t) * dt * alpha_t.
|
| 130 |
+
# lambda=1/2 is the classical trapezoid; the paper found NOT enforcing
|
| 131 |
+
# that (letting the model choose per step) is empirically better. Bias 0
|
| 132 |
+
# -> sigmoid 0.5 at init, so training starts from the classical rule.
|
| 133 |
+
self.lam_proj = nn.Linear(d_model, self.H)
|
| 134 |
+
nn.init.zeros_(self.lam_proj.weight)
|
| 135 |
+
nn.init.zeros_(self.lam_proj.bias)
|
| 136 |
+
self.out_proj = nn.Linear(d_model * rank, d_model)
|
| 137 |
+
self.norm = nn.RMSNorm(d_model) if hasattr(nn, "RMSNorm") else nn.LayerNorm(d_model)
|
| 138 |
+
|
| 139 |
+
# BCNorm / QKNorm (paper section 3.2). RMS normalisation AFTER the B and C
|
| 140 |
+
# projections, mirroring QKNorm in Transformers. The paper reports this
|
| 141 |
+
# is what stabilises large-scale runs, to the point that they remove
|
| 142 |
+
# Mamba-2's post-gate RMSNorm on the strength of it. It was missing from
|
| 143 |
+
# the first version of this file and E17's ssm arm then diverged
|
| 144 |
+
# (0.330 -> 19.313) into a constant predictor, which was patched with
|
| 145 |
+
# gradient clipping - a generic fix for a problem the paper had already
|
| 146 |
+
# solved specifically. Normalising over the N state dimensions, which is
|
| 147 |
+
# the axis B and C index into.
|
| 148 |
+
rms = nn.RMSNorm if hasattr(nn, "RMSNorm") else nn.LayerNorm
|
| 149 |
+
self.B_norm = rms(d_state)
|
| 150 |
+
self.C_norm = rms(d_state)
|
| 151 |
+
# B, C biases (paper appendix): learnable, head-specific, channel-wise,
|
| 152 |
+
# data-INDEPENDENT, added after the norm, initialised to all ones. The
|
| 153 |
+
# paper finds models insensitive to the init as long as it is positive,
|
| 154 |
+
# and that B and C biases together beat either alone.
|
| 155 |
+
self.B_bias = nn.Parameter(torch.ones(self.H, d_state, rank))
|
| 156 |
+
self.C_bias = nn.Parameter(torch.ones(self.H, d_state, rank))
|
| 157 |
+
|
| 158 |
+
# A is data-INDEPENDENT here. Remark 1: the paper found data-dependent
|
| 159 |
+
# A_t performs similarly and chose it for consistency; the simpler
|
| 160 |
+
# form is used here and the difference is recorded rather than hidden.
|
| 161 |
+
self.A_log = nn.Parameter(torch.log(torch.rand(self.H) * 0.9 + 0.05))
|
| 162 |
+
# dt bias initialised so Delta spans [dt_min, dt_max] - the standard
|
| 163 |
+
# Mamba init. Delta sets the memory horizon, so this is the prior on
|
| 164 |
+
# how long the layer remembers before it learns anything.
|
| 165 |
+
dt = torch.exp(torch.rand(self.H) * (math.log(dt_max) - math.log(dt_min))
|
| 166 |
+
+ math.log(dt_min))
|
| 167 |
+
self.dt_proj.bias.data = dt + torch.log(-torch.expm1(-dt))
|
| 168 |
+
|
| 169 |
+
def _coeffs(self, u: torch.Tensor):
|
| 170 |
+
"""alpha, beta, gamma, B, C, x, theta for every timestep."""
|
| 171 |
+
Bsz, T, _ = u.shape
|
| 172 |
+
H, N, R, P = self.H, self.N, self.R, self.P
|
| 173 |
+
dt = F.softplus(self.dt_proj(u)) # (B,T,H) > 0
|
| 174 |
+
A = -torch.exp(self.A_log) # (H,) < 0
|
| 175 |
+
alpha = torch.exp(dt * A) # (B,T,H) in (0,1)
|
| 176 |
+
# Exponential-trapezoidal with a DATA-DEPENDENT weight lambda_t (paper
|
| 177 |
+
# Prop 1 / eq 5-6): gamma_t = lambda_t*dt (current input), beta_t =
|
| 178 |
+
# (1-lambda_t)*dt*alpha_t (previous input). lambda=1/2 recovers the
|
| 179 |
+
# classical trapezoid; lambda=1 recovers Mamba-2's exponential-Euler.
|
| 180 |
+
lam = torch.sigmoid(self.lam_proj(u)) # (B,T,H) in (0,1)
|
| 181 |
+
gamma = lam * dt
|
| 182 |
+
beta = (1.0 - lam) * dt * alpha
|
| 183 |
+
# BCNorm then bias, in that order - the paper adds the bias AFTER the
|
| 184 |
+
# norm, so the bias is not rescaled by it.
|
| 185 |
+
Bm = self.B_proj(u).view(Bsz, T, H, R, N)
|
| 186 |
+
Bm = self.B_norm(Bm).permute(0, 1, 2, 4, 3) + self.B_bias
|
| 187 |
+
Cm = self.C_proj(u).view(Bsz, T, H, R, N)
|
| 188 |
+
Cm = self.C_norm(Cm).permute(0, 1, 2, 4, 3) + self.C_bias
|
| 189 |
+
x = self.x_proj(u).view(Bsz, T, H, P, R)
|
| 190 |
+
theta = self.theta_proj(u).view(Bsz, T, H, N // 2) * dt.unsqueeze(-1)
|
| 191 |
+
return alpha, beta, gamma, Bm, Cm, x, theta
|
| 192 |
+
|
| 193 |
+
def gate_and_proj(self, y_flat: torch.Tensor, nu: torch.Tensor):
|
| 194 |
+
"""Apply the Mamba gate then the output projection.
|
| 195 |
+
|
| 196 |
+
y_flat: (..., d_model*R) SSM output before out_proj.
|
| 197 |
+
nu: (..., d_model) the NORMALISED layer input (in_proj sees the
|
| 198 |
+
same normalised input the SSM path does).
|
| 199 |
+
out = out_proj( y_flat * SiLU(in_proj(nu)) ).
|
| 200 |
+
"""
|
| 201 |
+
return self.out_proj(y_flat * F.silu(self.in_proj(nu)))
|
| 202 |
+
|
| 203 |
+
def forward(self, u: torch.Tensor, state=None, return_state=False):
|
| 204 |
+
"""Recurrent path. u: (B,T,d_model). This is the streaming form.
|
| 205 |
+
|
| 206 |
+
The recurrence itself lives in the shared `ssm_scan` (paper eq 11), so
|
| 207 |
+
this and MMSECore.forward cannot drift. Here the per-step write is the
|
| 208 |
+
single-stream outer product S_t = B_t x_t^T.
|
| 209 |
+
"""
|
| 210 |
+
nu = self.norm(u)
|
| 211 |
+
alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu)
|
| 212 |
+
S = Bm @ x.transpose(-1, -2) # (B,T,H,N,P)
|
| 213 |
+
y_flat, h, _ = ssm_scan(alpha, beta, gamma, theta, S, Cm, self.P,
|
| 214 |
+
state=state)
|
| 215 |
+
out = self.gate_and_proj(y_flat, nu)
|
| 216 |
+
return (out, h) if return_state else out
|
| 217 |
+
|
| 218 |
+
def step(self, u_t: torch.Tensor, state, prev_bx=None):
|
| 219 |
+
"""One timestep, constant memory. The deployment path.
|
| 220 |
+
|
| 221 |
+
state: (B,H,N,P) or None. prev_bx: B_{t-1}x_{t-1}^T for the beta term,
|
| 222 |
+
which the trapezoidal rule requires and Mamba-2 does not have.
|
| 223 |
+
"""
|
| 224 |
+
Bsz = u_t.shape[0]
|
| 225 |
+
nu = self.norm(u_t.unsqueeze(1))
|
| 226 |
+
alpha, beta, gamma, Bm, Cm, x, theta = self._coeffs(nu)
|
| 227 |
+
H, N, P = self.H, self.N, self.P
|
| 228 |
+
h = torch.zeros(Bsz, H, N, P, device=u_t.device, dtype=u_t.dtype) \
|
| 229 |
+
if state is None else state
|
| 230 |
+
# Match forward/eq(11): rotate (alpha*h + beta*prev_bx) by R_t, then add
|
| 231 |
+
# the current (unrotated) write. prev_bx = B_{t-1}x_{t-1}^T from the last
|
| 232 |
+
# step; bx = B_t x_t^T is returned for the next step's beta term.
|
| 233 |
+
pre = alpha[:, 0, :, None, None] * h
|
| 234 |
+
if prev_bx is not None:
|
| 235 |
+
pre = pre + beta[:, 0, :, None, None] * prev_bx
|
| 236 |
+
h = rotate_pairs(pre.transpose(-1, -2), theta[:, 0].unsqueeze(-2)
|
| 237 |
+
.expand(-1, -1, P, -1)).transpose(-1, -2)
|
| 238 |
+
bx = Bm[:, 0] @ x[:, 0].transpose(-1, -2)
|
| 239 |
+
h = h + gamma[:, 0, :, None, None] * bx
|
| 240 |
+
y = (Cm[:, 0].transpose(-1, -2) @ h).reshape(Bsz, -1)
|
| 241 |
+
return self.gate_and_proj(y, nu[:, 0]), h, bx
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ---------------------------------------------------------------------------
|
| 245 |
+
def selftest(verbose: bool = True) -> dict:
|
| 246 |
+
"""The four invariants, at float64.
|
| 247 |
+
|
| 248 |
+
These were an ad-hoc scratch script until BCNorm was added and there was
|
| 249 |
+
nothing to re-run. A verified file whose verification lives somewhere else
|
| 250 |
+
is an unverified file the moment anyone edits it.
|
| 251 |
+
|
| 252 |
+
python nir/mamba3.py
|
| 253 |
+
"""
|
| 254 |
+
torch.manual_seed(0)
|
| 255 |
+
torch.set_default_dtype(torch.float64)
|
| 256 |
+
net = Mamba3Layer(d_model=32, d_state=16, rank=2, head_dim=8).eval()
|
| 257 |
+
B, T = 2, 20
|
| 258 |
+
u = torch.randn(B, T, 32)
|
| 259 |
+
out = {}
|
| 260 |
+
|
| 261 |
+
with torch.no_grad():
|
| 262 |
+
y = net(u)
|
| 263 |
+
|
| 264 |
+
# 1. CAUSALITY, as a SCALE test rather than an equality one. Appending
|
| 265 |
+
# future timesteps moves the past by ~1e-15 here: the permute in
|
| 266 |
+
# _coeffs leaves B and C non-contiguous, so matmul takes a different
|
| 267 |
+
# kernel path at a different T and the last bits differ. An equality
|
| 268 |
+
# assertion would call that a leak, and would also fail spuriously in
|
| 269 |
+
# float32. A real leak scales with the size of the future; rounding
|
| 270 |
+
# does not. Drive the future through four orders of magnitude and
|
| 271 |
+
# require the drift to stay put.
|
| 272 |
+
drifts = []
|
| 273 |
+
for mag in (1.0, 1e2, 1e4):
|
| 274 |
+
u2 = torch.cat([u, torch.randn(B, 6, 32) * mag], 1)
|
| 275 |
+
drifts.append(float((net(u2)[:, :T] - y).abs().max()))
|
| 276 |
+
out["causality"] = max(drifts)
|
| 277 |
+
out["causality_scaling"] = max(drifts) / max(min(drifts), 1e-300)
|
| 278 |
+
|
| 279 |
+
# 2. STREAMING == BATCHED. The deployment path must equal training.
|
| 280 |
+
st = bx = None
|
| 281 |
+
steps = []
|
| 282 |
+
for t in range(T):
|
| 283 |
+
o, st, bx = net.step(u[:, t], st, bx)
|
| 284 |
+
steps.append(o)
|
| 285 |
+
out["streaming"] = float((torch.stack(steps, 1) - y).abs().max())
|
| 286 |
+
|
| 287 |
+
# 3. ROTATION IS ORTHOGONAL. State norm preserved, so it cannot blow up.
|
| 288 |
+
h = torch.randn(B, 4, 16, 8)
|
| 289 |
+
ang = torch.randn(B, 4, 1, 8)
|
| 290 |
+
hr = rotate_pairs(h.transpose(-1, -2), ang).transpose(-1, -2)
|
| 291 |
+
out["orthogonal"] = float(
|
| 292 |
+
(hr.norm(dim=-2) - h.norm(dim=-2)).abs().max())
|
| 293 |
+
|
| 294 |
+
# 4. TRAPEZOIDAL TWO-TAP. Perturbing x_{t-1} moves y_t, not y_{t-2}.
|
| 295 |
+
up = u.clone(); up[:, 3] += 1.0
|
| 296 |
+
yp = net(up)
|
| 297 |
+
out["twotap_y4"] = float((yp[:, 4] - y[:, 4]).abs().max())
|
| 298 |
+
out["twotap_y2"] = float((yp[:, 2] - y[:, 2]).abs().max())
|
| 299 |
+
|
| 300 |
+
# 5. BCNORM IS ACTIVE. B's pre-bias rows should have unit RMS. Without
|
| 301 |
+
# the norm this is whatever the projection happens to produce, and
|
| 302 |
+
# the first version of this file had no norm at all.
|
| 303 |
+
a, b, g, Bm, Cm, x, th = net._coeffs(net.norm(u))
|
| 304 |
+
# Bm is (B,T,H,N,R) after the permute, so the normalised axis is -2.
|
| 305 |
+
rms = (Bm - net.B_bias).pow(2).mean(-2).sqrt()
|
| 306 |
+
out["bcnorm_rms"] = float((rms - 1.0).abs().max())
|
| 307 |
+
|
| 308 |
+
out["params"] = sum(p.numel() for p in net.parameters())
|
| 309 |
+
torch.set_default_dtype(torch.float32)
|
| 310 |
+
|
| 311 |
+
if verbose:
|
| 312 |
+
ok = lambda c: "OK" if c else "FAIL"
|
| 313 |
+
print("Mamba-3 selftest (float64)")
|
| 314 |
+
print(f" 1 causality (future->past) {out['causality']:.3e}"
|
| 315 |
+
f" {ok(out['causality'] < 1e-12)}")
|
| 316 |
+
print(f" drift x1 vs x10000 ratio {out['causality_scaling']:.3f}"
|
| 317 |
+
f" {ok(out['causality_scaling'] < 1.5)} (a leak would scale)")
|
| 318 |
+
print(f" 2 step() == forward() {out['streaming']:.3e}"
|
| 319 |
+
f" {ok(out['streaming'] < 1e-10)}")
|
| 320 |
+
print(f" 3 rotation orthogonal {out['orthogonal']:.3e}"
|
| 321 |
+
f" {ok(out['orthogonal'] < 1e-10)}")
|
| 322 |
+
print(f" 4 trapezoidal two-tap y4 {out['twotap_y4']:.3e}"
|
| 323 |
+
f" y2 {out['twotap_y2']:.3e}"
|
| 324 |
+
f" {ok(out['twotap_y4'] > 1e-6 and out['twotap_y2'] == 0.0)}")
|
| 325 |
+
print(f" 5 BCNorm rows unit RMS {out['bcnorm_rms']:.3e}"
|
| 326 |
+
f" {ok(out['bcnorm_rms'] < 1e-10)}")
|
| 327 |
+
print(f" {out['params']:,} params")
|
| 328 |
+
return out
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
if __name__ == "__main__":
|
| 332 |
+
selftest()
|
arctag/_core/arc_audio/mamba3/scan.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chunked parallel scan for the Mamba-3 recurrence.
|
| 2 |
+
|
| 3 |
+
WHY. The recurrent path is a Python loop of T sequential steps. Measured on
|
| 4 |
+
this laptop: d_model 256 / 4 layers / N 32 runs at 3,670 tok/s, while
|
| 5 |
+
384 / 6 / 64 runs at 179 tok/s - 20x slower for ~4x the FLOPs. The gap is not
|
| 6 |
+
arithmetic, it is kernel-launch overhead: 6 layers x 128 timesteps is 768
|
| 7 |
+
sequential iterations each firing a handful of tiny kernels, so DEPTH
|
| 8 |
+
MULTIPLIES LAUNCHES. That is what blocks scaling, not parameters.
|
| 9 |
+
|
| 10 |
+
THE TRICK, and it is the paper's own (Proposition 3). The recurrence
|
| 11 |
+
|
| 12 |
+
h_t = alpha_t R(theta_t) h_{t-1} + beta_t S_{t-1} + gamma_t S_t
|
| 13 |
+
|
| 14 |
+
has a data-dependent rotation inside it, which a scan cannot factor. But
|
| 15 |
+
rotations compose: define the CUMULATIVE angle Phi_t = sum_{s<=t} theta_s and
|
| 16 |
+
change basis by R(-Phi_t). In that frame the rotation disappears and what is
|
| 17 |
+
left is a plain first-order linear recurrence
|
| 18 |
+
|
| 19 |
+
g_t = alpha_t g_{t-1} + w_t w_t = R(-Phi_t) (beta_t S_{t-1} + gamma_t S_t)
|
| 20 |
+
|
| 21 |
+
with h_t = R(Phi_t) g_t. The paper states this as applying data-dependent
|
| 22 |
+
RoPE to B and C; the same identity in state space is what makes the scan
|
| 23 |
+
possible at all.
|
| 24 |
+
|
| 25 |
+
THE SCAN. g_t = alpha_t g_{t-1} + w_t has the closed form
|
| 26 |
+
|
| 27 |
+
g_t = A_t * sum_{s<=t} w_s / A_s A_t = prod_{r<=t} alpha_r
|
| 28 |
+
|
| 29 |
+
which is numerically hopeless directly - A_s underflows for long sequences
|
| 30 |
+
and the division explodes. So this chunks: within a chunk of length L the sum
|
| 31 |
+
is materialised as an L x L lower-triangular decay matrix (no division, decay
|
| 32 |
+
is always relative to the chunk start), and chunk states are carried
|
| 33 |
+
recurrently. T/L sequential steps instead of T. The intra-chunk work becomes
|
| 34 |
+
one batched matmul, which is what GPUs are for.
|
| 35 |
+
|
| 36 |
+
CORRECTNESS IS CHECKED AGAINST THE RECURRENCE, NOT AGAINST A LOSS CURVE.
|
| 37 |
+
`verify()` compares this to Mamba3Layer.forward at float64. A fast scan that
|
| 38 |
+
computes something slightly different would show up as a slightly worse loss
|
| 39 |
+
curve and be indistinguishable from a hyperparameter difference - which is
|
| 40 |
+
exactly how this project has lost time before.
|
| 41 |
+
|
| 42 |
+
python nir/scan.py
|
| 43 |
+
"""
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import math
|
| 47 |
+
import torch
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def cumulative_angles(theta: torch.Tensor) -> torch.Tensor:
|
| 51 |
+
"""Phi_t = sum_{s<=t} theta_s. theta: (B,T,H,N/2)."""
|
| 52 |
+
return torch.cumsum(theta, dim=1)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def rotate_by(v: torch.Tensor, ang: torch.Tensor) -> torch.Tensor:
|
| 56 |
+
"""Rotate pairs of the N axis. v: (...,N,P), ang: (...,N/2) broadcastable.
|
| 57 |
+
|
| 58 |
+
Built with stack+reshape rather than writing into an empty tensor. The
|
| 59 |
+
first version used `out = torch.empty_like(v)` with strided assignment,
|
| 60 |
+
which is correct in the forward direction and BREAKS AUTOGRAD: in-place
|
| 61 |
+
writes bump the version counter of a tensor the graph still needs. The
|
| 62 |
+
forward equivalence check passed anyway, because it ran under no_grad.
|
| 63 |
+
"""
|
| 64 |
+
a, b = v[..., 0::2, :], v[..., 1::2, :]
|
| 65 |
+
c, s = torch.cos(ang).unsqueeze(-1), torch.sin(ang).unsqueeze(-1)
|
| 66 |
+
even, odd = c * a - s * b, s * a + c * b
|
| 67 |
+
return torch.stack((even, odd), dim=-2).flatten(-3, -2)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def chunked_scan(alpha: torch.Tensor, w: torch.Tensor, chunk: int = 64,
|
| 71 |
+
state=None):
|
| 72 |
+
"""g_t = alpha_t g_{t-1} + w_t, computed in chunks.
|
| 73 |
+
|
| 74 |
+
alpha: (B,T,H) per-step decay, in (0,1)
|
| 75 |
+
w: (B,T,H,N,P) per-step input, already in the rotated frame
|
| 76 |
+
returns g: (B,T,H,N,P) and the final state.
|
| 77 |
+
|
| 78 |
+
Within a chunk, g_t = d_t * g_0 + sum_{s<=t} (d_t/d_s) w_s where
|
| 79 |
+
d_t = prod_{r<=t} alpha_r RELATIVE TO THE CHUNK START. Both d_t and d_t/d_s
|
| 80 |
+
stay bounded because the product never runs longer than `chunk` steps -
|
| 81 |
+
that is the whole reason for chunking rather than a global cumprod.
|
| 82 |
+
"""
|
| 83 |
+
B, T, H = alpha.shape
|
| 84 |
+
N, P = w.shape[-2], w.shape[-1]
|
| 85 |
+
# Accumulate chunks in a list and cat once. Writing into a preallocated
|
| 86 |
+
# g_out and then reading g_out[:, e-1] back as the next chunk's state is
|
| 87 |
+
# an in-place write into a tensor autograd still needs.
|
| 88 |
+
pieces = []
|
| 89 |
+
g = torch.zeros(B, H, N, P, device=w.device, dtype=w.dtype) \
|
| 90 |
+
if state is None else state
|
| 91 |
+
la = torch.log(alpha.clamp_min(1e-30)) # (B,T,H)
|
| 92 |
+
for s in range(0, T, chunk):
|
| 93 |
+
e = min(s + chunk, T)
|
| 94 |
+
L = e - s
|
| 95 |
+
cl = torch.cumsum(la[:, s:e], dim=1) # (B,L,H) log d_t
|
| 96 |
+
d = torch.exp(cl) # decay from chunk start
|
| 97 |
+
# (d_t / d_s) for s<=t, lower triangular, exp of a difference of logs
|
| 98 |
+
rel = cl.unsqueeze(2) - cl.unsqueeze(1) # (B,L,L,H) log d_t - log d_s
|
| 99 |
+
mask = torch.tril(torch.ones(L, L, device=w.device, dtype=torch.bool))
|
| 100 |
+
M = torch.where(mask.view(1, L, L, 1), torch.exp(rel),
|
| 101 |
+
torch.zeros((), device=w.device, dtype=w.dtype))
|
| 102 |
+
# intra-chunk: sum_s M[t,s] * w_s -> one batched matmul over (L,L) x (L, N*P)
|
| 103 |
+
wf = w[:, s:e].reshape(B, L, H, N * P).permute(0, 2, 1, 3) # (B,H,L,NP)
|
| 104 |
+
Mp = M.permute(0, 3, 1, 2) # (B,H,L,L)
|
| 105 |
+
acc = (Mp @ wf).permute(0, 2, 1, 3).reshape(B, L, H, N, P)
|
| 106 |
+
# carry the incoming chunk state, decayed
|
| 107 |
+
piece = acc + d.unsqueeze(-1).unsqueeze(-1) * g.unsqueeze(1)
|
| 108 |
+
pieces.append(piece)
|
| 109 |
+
g = piece[:, -1]
|
| 110 |
+
return torch.cat(pieces, dim=1), g
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def scan_forward(layer, u: torch.Tensor, chunk: int = 64):
|
| 114 |
+
"""Mamba3Layer.forward computed with the chunked scan instead of the loop."""
|
| 115 |
+
Bsz, T, _ = u.shape
|
| 116 |
+
H, N, R, P = layer.H, layer.N, layer.R, layer.P
|
| 117 |
+
nu = layer.norm(u)
|
| 118 |
+
alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
|
| 119 |
+
S = Bm @ x.transpose(-1, -2) # (B,T,H,N,P)
|
| 120 |
+
# trapezoidal: beta carries the PREVIOUS step's write. Built by cat, not
|
| 121 |
+
# by assigning into a zeros tensor, for the same autograd reason.
|
| 122 |
+
Sprev = torch.cat([torch.zeros_like(S[:, :1]), S[:, :-1]], dim=1)
|
| 123 |
+
Phi = cumulative_angles(theta) # (B,T,H,N/2) = Phi_s
|
| 124 |
+
Phi_prev = torch.cat([torch.zeros_like(Phi[:, :1]), Phi[:, :-1]], dim=1)
|
| 125 |
+
# Paper eq (11): the gamma (current) write rotates by R(-Phi_s), the beta
|
| 126 |
+
# (previous) write by R(-Phi_{s-1}) - one step less. Rotate the two terms
|
| 127 |
+
# separately into the global frame, then scan.
|
| 128 |
+
w = (gamma[..., None, None] * rotate_by(S, -Phi)
|
| 129 |
+
+ beta[..., None, None] * rotate_by(Sprev, -Phi_prev))
|
| 130 |
+
g, _ = chunked_scan(alpha, w, chunk)
|
| 131 |
+
h = rotate_by(g, Phi) # back out
|
| 132 |
+
y = (Cm.transpose(-1, -2) @ h).reshape(Bsz, T, H * R * P)
|
| 133 |
+
return layer.gate_and_proj(y, nu)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def verify(chunks=(8, 16, 64), verbose: bool = True) -> dict:
|
| 137 |
+
"""Against the recurrence, at float64 - FORWARD AND BACKWARD.
|
| 138 |
+
|
| 139 |
+
The backward half is not optional. The first version of this file passed
|
| 140 |
+
every forward check and could not backpropagate at all: it built outputs
|
| 141 |
+
with in-place strided writes, which autograd rejects. The forward checks
|
| 142 |
+
ran under no_grad and saw nothing. A scan that cannot train is useless,
|
| 143 |
+
and one that trains with subtly wrong gradients is worse than useless -
|
| 144 |
+
it would surface as a slightly worse loss curve, indistinguishable from a
|
| 145 |
+
hyperparameter difference."""
|
| 146 |
+
import sys
|
| 147 |
+
from pathlib import Path
|
| 148 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 149 |
+
from mamba3.layer import Mamba3Layer
|
| 150 |
+
|
| 151 |
+
torch.manual_seed(0)
|
| 152 |
+
torch.set_default_dtype(torch.float64)
|
| 153 |
+
out = {}
|
| 154 |
+
lay = Mamba3Layer(d_model=32, d_state=16, rank=2, head_dim=8).eval()
|
| 155 |
+
u = torch.randn(3, 40, 32)
|
| 156 |
+
with torch.no_grad():
|
| 157 |
+
ref = lay(u)
|
| 158 |
+
for c in chunks:
|
| 159 |
+
got = scan_forward(lay, u, chunk=c)
|
| 160 |
+
out[f"chunk{c}"] = float((got - ref).abs().max())
|
| 161 |
+
# a length that is not a multiple of the chunk
|
| 162 |
+
u2 = torch.randn(2, 37, 32)
|
| 163 |
+
r2 = lay(u2)
|
| 164 |
+
out["ragged"] = float((scan_forward(lay, u2, chunk=16) - r2).abs().max())
|
| 165 |
+
# causality of the scan path itself
|
| 166 |
+
u3 = torch.cat([u, torch.randn(3, 9, 32) * 50], 1)
|
| 167 |
+
out["causality"] = float(
|
| 168 |
+
(scan_forward(lay, u3, chunk=16)[:, :40]
|
| 169 |
+
- scan_forward(lay, u, chunk=16)).abs().max())
|
| 170 |
+
|
| 171 |
+
# ---- BACKWARD ----
|
| 172 |
+
ug = torch.randn(3, 24, 32, requires_grad=True)
|
| 173 |
+
lay.zero_grad()
|
| 174 |
+
lay(ug).pow(2).sum().backward()
|
| 175 |
+
gu_rec = ug.grad.clone()
|
| 176 |
+
gp_rec = {n: p.grad.clone() for n, p in lay.named_parameters()
|
| 177 |
+
if p.grad is not None}
|
| 178 |
+
ug.grad = None
|
| 179 |
+
lay.zero_grad()
|
| 180 |
+
scan_forward(lay, ug, chunk=8).pow(2).sum().backward()
|
| 181 |
+
out["grad_input"] = float((gu_rec - ug.grad).abs().max())
|
| 182 |
+
out["grad_params"] = max(
|
| 183 |
+
float((gp_rec[n] - p.grad).abs().max())
|
| 184 |
+
for n, p in lay.named_parameters() if p.grad is not None)
|
| 185 |
+
torch.set_default_dtype(torch.float32)
|
| 186 |
+
if verbose:
|
| 187 |
+
ok = lambda c: "OK " if c else "FAIL"
|
| 188 |
+
print("chunked scan vs recurrence (float64)")
|
| 189 |
+
for k, v in out.items():
|
| 190 |
+
print(f" {k:<12}{v:.3e} {ok(v < 1e-9)}")
|
| 191 |
+
return out
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == "__main__":
|
| 195 |
+
verify()
|
arctag/_core/arc_audio/mamba3/ssd.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chunked SSD evaluation of Mamba3Layer (copied verbatim from WhissleAI's internal Mamba-3 code)."""
|
| 2 |
+
import torch
|
| 3 |
+
from .scan import rotate_by
|
| 4 |
+
from .layer import Mamba3Layer
|
| 5 |
+
|
| 6 |
+
def _f(t):
|
| 7 |
+
return t.float() if t is not None and t.dtype in (torch.float16, torch.bfloat16) else t
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _shift(x):
|
| 11 |
+
return torch.cat([torch.zeros_like(x[:, :1]), x[:, :-1]], dim=1)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def ssd_safe(alpha, Beff, xeff, Ctil, chunk: int = 64):
|
| 15 |
+
"""Chunked SSD (as vendor ssd_pytorch) with the decay masked before exp. Returns y (B,T,H,R,P)."""
|
| 16 |
+
B, T, H, N, K = Beff.shape
|
| 17 |
+
P = xeff.shape[-2]
|
| 18 |
+
la = torch.log(alpha.clamp_min(1e-30))
|
| 19 |
+
g = torch.zeros(B, H, N, P, dtype=Beff.dtype, device=Beff.device)
|
| 20 |
+
ys = []
|
| 21 |
+
for s0 in range(0, T, chunk):
|
| 22 |
+
e = min(s0 + chunk, T)
|
| 23 |
+
L = e - s0
|
| 24 |
+
Bc, xc, Cc = Beff[:, s0:e], xeff[:, s0:e], Ctil[:, s0:e]
|
| 25 |
+
cl = torch.cumsum(la[:, s0:e], dim=1)
|
| 26 |
+
d = torch.exp(cl)
|
| 27 |
+
rel = cl.unsqueeze(2) - cl.unsqueeze(1) # (B,L,L,H)
|
| 28 |
+
tril = torch.tril(torch.ones(L, L, dtype=torch.bool, device=Beff.device)).view(1, L, L, 1)
|
| 29 |
+
Ldec = torch.exp(rel.masked_fill(~tril, float("-inf")))
|
| 30 |
+
G = torch.einsum('blhnr,bmhnk->bhlmrk', Cc, Bc)
|
| 31 |
+
y_intra = torch.einsum('blmh,bhlmrk,bmhpk->blhrp', Ldec, G, xc)
|
| 32 |
+
y_inter = torch.einsum('blh,blhnr,bhnp->blhrp', d, Cc, g)
|
| 33 |
+
ys.append(y_intra + y_inter)
|
| 34 |
+
coef = torch.exp(cl[:, -1:, :] - cl)
|
| 35 |
+
g = d[:, -1].unsqueeze(-1).unsqueeze(-1) * g + torch.einsum('blh,blhnk,blhpk->bhnp', coef, Bc, xc)
|
| 36 |
+
return torch.cat(ys, dim=1)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _ssd_from_coeffs(alpha, beta, gamma, Bc, xc, Cm, theta, chunk):
|
| 40 |
+
"""Bc (B,T,H,N,K) xc (B,T,H,P,K) Cm (B,T,H,N,R) -> y (B,T,H,R,P); the vendor ssd_inputs reduction."""
|
| 41 |
+
Phi = torch.cumsum(theta, dim=1)
|
| 42 |
+
Phi_prev = _shift(Phi)
|
| 43 |
+
Btil = rotate_by(Bc, -Phi)
|
| 44 |
+
Ctil = rotate_by(Cm, -Phi)
|
| 45 |
+
Btil_prev = rotate_by(_shift(Bc), -Phi_prev)
|
| 46 |
+
Beff = torch.cat([gamma[..., None, None] * Btil, beta[..., None, None] * Btil_prev], dim=-1)
|
| 47 |
+
xeff = torch.cat([xc, _shift(xc)], dim=-1)
|
| 48 |
+
return ssd_safe(alpha, Beff, xeff, Ctil, chunk)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def mamba3_layer_ssd(layer: Mamba3Layer, u: torch.Tensor, chunk: int = 64):
|
| 52 |
+
"""Same output as Mamba3Layer.forward(u) via SSD."""
|
| 53 |
+
nu = layer.norm(u)
|
| 54 |
+
alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
|
| 55 |
+
y = _ssd_from_coeffs(alpha, beta, gamma, Bm, x, Cm, theta, chunk)
|
| 56 |
+
Bsz, T = u.shape[:2]
|
| 57 |
+
return layer.gate_and_proj(y.reshape(Bsz, T, -1), nu)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---- stateful SSD (carried state across calls; truncated-BPTT training and reset-free inference) -------------------
|
| 63 |
+
def ssd_safe_state(alpha, Beff, xeff, Ctil, g0=None, chunk: int = 64):
|
| 64 |
+
"""ssd_safe with an initial inter-chunk state g0 (B,H,N,P) (rotated frame of t = -1); returns (y, g_final)."""
|
| 65 |
+
B, T, H, N, K = Beff.shape
|
| 66 |
+
P = xeff.shape[-2]
|
| 67 |
+
la = torch.log(alpha.clamp_min(1e-30))
|
| 68 |
+
g = torch.zeros(B, H, N, P, dtype=Beff.dtype, device=Beff.device) if g0 is None else g0.to(Beff.dtype)
|
| 69 |
+
ys = []
|
| 70 |
+
for s0 in range(0, T, chunk):
|
| 71 |
+
e = min(s0 + chunk, T)
|
| 72 |
+
L = e - s0
|
| 73 |
+
Bc, xc, Cc = Beff[:, s0:e], xeff[:, s0:e], Ctil[:, s0:e]
|
| 74 |
+
cl = torch.cumsum(la[:, s0:e], dim=1)
|
| 75 |
+
d = torch.exp(cl)
|
| 76 |
+
rel = cl.unsqueeze(2) - cl.unsqueeze(1)
|
| 77 |
+
tril = torch.tril(torch.ones(L, L, dtype=torch.bool, device=Beff.device)).view(1, L, L, 1)
|
| 78 |
+
Ldec = torch.exp(rel.masked_fill(~tril, float("-inf")))
|
| 79 |
+
G = torch.einsum('blhnr,bmhnk->bhlmrk', Cc, Bc)
|
| 80 |
+
y_intra = torch.einsum('blmh,bhlmrk,bmhpk->blhrp', Ldec, G, xc)
|
| 81 |
+
y_inter = torch.einsum('blh,blhnr,bhnp->blhrp', d, Cc, g)
|
| 82 |
+
ys.append(y_intra + y_inter)
|
| 83 |
+
coef = torch.exp(cl[:, -1:, :] - cl)
|
| 84 |
+
g = d[:, -1].unsqueeze(-1).unsqueeze(-1) * g + torch.einsum('blh,blhnk,blhpk->bhnp', coef, Bc, xc)
|
| 85 |
+
return torch.cat(ys, dim=1), g
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def mamba3_layer_ssd_state(layer: Mamba3Layer, u: torch.Tensor, state=None, chunk: int = 64):
|
| 89 |
+
"""SSD with carried state. state = (h (B,H,N,P) absolute frame, B_last (B,H,N,K), x_last (B,H,P,K)) or None (zeros).
|
| 90 |
+
Returns (out, new_state). Equal to running mamba3_layer_ssd over the concatenated sequence; step()'s prev_bx = B_last @ x_last^T."""
|
| 91 |
+
nu = layer.norm(u)
|
| 92 |
+
alpha, beta, gamma, Bm, Cm, x, theta = layer._coeffs(nu)
|
| 93 |
+
Phi = torch.cumsum(theta, dim=1)
|
| 94 |
+
Phi_prev = _shift(Phi)
|
| 95 |
+
if state is None:
|
| 96 |
+
h0 = None; Bp = torch.zeros_like(Bm[:, :1]); xp = torch.zeros_like(x[:, :1])
|
| 97 |
+
else:
|
| 98 |
+
h0, Bl, xl = state; Bp = Bl[:, None].to(Bm.dtype); xp = xl[:, None].to(x.dtype)
|
| 99 |
+
Bsh = torch.cat([Bp, Bm[:, :-1]], 1); xsh = torch.cat([xp, x[:, :-1]], 1)
|
| 100 |
+
Btil = rotate_by(Bm, -Phi); Ctil = rotate_by(Cm, -Phi); Btil_prev = rotate_by(Bsh, -Phi_prev)
|
| 101 |
+
Beff = torch.cat([gamma[..., None, None] * Btil, beta[..., None, None] * Btil_prev], dim=-1)
|
| 102 |
+
xeff = torch.cat([x, xsh], dim=-1)
|
| 103 |
+
y, g = ssd_safe_state(alpha, Beff, xeff, Ctil, h0, chunk)
|
| 104 |
+
hT = rotate_by(g.unsqueeze(1), Phi[:, -1:])[:, 0]
|
| 105 |
+
Bsz, T = u.shape[:2]
|
| 106 |
+
return layer.gate_and_proj(y.reshape(Bsz, T, -1), nu), (hT, Bm[:, -1], x[:, -1])
|
arctag/_core/arc_audio/model.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc v1.4 network (inference only; 1.4.1 adds the word channel, arc_audio/wordch.py).
|
| 2 |
+
|
| 3 |
+
trunk: x (B,T,640) 10 Hz [mean,max]-pooled Moonshine Streaming features -> LayerNorm -> Linear 512 (+ domain emb + delay emb)
|
| 4 |
+
-> 5 x [FiLM(domain + delay) -> Mamba-3 + GELU MLP] -> per-domain rank-64 adapter -> RMSNorm -> h (B,T,512).
|
| 5 |
+
head: one flat tag-prompt head over the tag table: key_t = P e_t + r_t (e_t = frozen text embedding of the tag description,
|
| 6 |
+
shipped as a buffer); z = <Q_q(pool_q(h)), key_t> + b_t + b_t^q for each read-out q (frame, span_in, span_end, utt,
|
| 7 |
+
speaker, ev_at, ev_imm, ev_rec, item). Pooled read-outs carry (sum, n) state: utt (since the utterance start), speaker
|
| 8 |
+
(per speaker index), item (since the stream start).
|
| 9 |
+
extra: name pointer + numeral (NWT) value head (EntityHeads), diarization slots + voice embedding (DiarHeads), non-speech
|
| 10 |
+
vocal events (BioHead).
|
| 11 |
+
|
| 12 |
+
Two equivalent trunk paths:
|
| 13 |
+
Trunk.forward(x, ..., state) chunked SSD with carried state (offline / large chunks)
|
| 14 |
+
Trunk.step(x_t, ..., state) recurrent Mamba3Layer.step, one 100 ms frame at a time (true streaming; constant cost)
|
| 15 |
+
Both carry the same per-layer state; tests/test_release.py checks that they agree.
|
| 16 |
+
"""
|
| 17 |
+
import math
|
| 18 |
+
import torch, torch.nn as nn, torch.nn.functional as F
|
| 19 |
+
|
| 20 |
+
from .mamba3 import Mamba3Layer
|
| 21 |
+
from .mamba3.layer import rotate_pairs
|
| 22 |
+
from .mamba3.ssd import mamba3_layer_ssd_state, _f
|
| 23 |
+
from .ontology import QUERIES, POOLED, FINE_DOMAINS
|
| 24 |
+
|
| 25 |
+
NEG = -1e4
|
| 26 |
+
S_MAX = 64 # speaker indices pooled per stream
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Trunk(nn.Module):
|
| 30 |
+
def __init__(self, d_in=640, d_model=512, n_layers=5, d_state=64, head_dim=64, dropout=0.2, in_drop=0.2, chunk=128,
|
| 31 |
+
n_dom=len(FINE_DOMAINS), delays=(0, 1, 2, 3, 5, 8), adapters=64, long_mem=True):
|
| 32 |
+
super().__init__()
|
| 33 |
+
self.cfg = dict(d_in=d_in, d_model=d_model, n_layers=n_layers, d_state=d_state, head_dim=head_dim, dropout=dropout, in_drop=in_drop,
|
| 34 |
+
chunk=chunk, n_dom=n_dom, delays=list(delays), adapters=adapters, long_mem=long_mem)
|
| 35 |
+
self.chunk = chunk
|
| 36 |
+
self.in_norm = nn.LayerNorm(d_in); self.inp = nn.Linear(d_in, d_model)
|
| 37 |
+
self.dom_emb = nn.Embedding(n_dom, d_model)
|
| 38 |
+
self.film = nn.ModuleList([nn.Linear(d_model, 2 * d_model) for _ in range(n_layers)])
|
| 39 |
+
self.delays = list(delays)
|
| 40 |
+
self.delay_emb = nn.Embedding(len(delays), d_model)
|
| 41 |
+
self.delay_film = nn.Embedding(len(delays), d_model)
|
| 42 |
+
lut = torch.full((max(delays) + 1,), -1, dtype=torch.long)
|
| 43 |
+
for i, d in enumerate(delays):
|
| 44 |
+
lut[d] = i
|
| 45 |
+
self.register_buffer("delay_lut", lut, persistent=False)
|
| 46 |
+
self.adapt_down = nn.Parameter(torch.zeros(n_dom, d_model, adapters))
|
| 47 |
+
self.adapt_up = nn.Parameter(torch.zeros(n_dom, adapters, d_model))
|
| 48 |
+
self.layers = nn.ModuleList([Mamba3Layer(d_model, d_state=d_state, rank=1, head_dim=head_dim) for _ in range(n_layers)])
|
| 49 |
+
self.mlps = nn.ModuleList([nn.Sequential(nn.RMSNorm(d_model), nn.Linear(d_model, 2 * d_model), nn.GELU(), nn.Dropout(dropout),
|
| 50 |
+
nn.Linear(2 * d_model, d_model)) for _ in range(n_layers)])
|
| 51 |
+
self.out_norm = nn.RMSNorm(d_model)
|
| 52 |
+
|
| 53 |
+
def _cond(self, x, dom, delay):
|
| 54 |
+
di = self.delay_lut[delay.clamp(0, len(self.delay_lut) - 1)]
|
| 55 |
+
if (di < 0).any():
|
| 56 |
+
raise ValueError(f"delay {delay.tolist()} frames not in the trained set {self.delays}")
|
| 57 |
+
h = self.inp(self.in_norm(x))
|
| 58 |
+
e = self.dom_emb(dom) + self.delay_film(di)
|
| 59 |
+
return h + (self.dom_emb(dom) + self.delay_emb(di))[:, None], e
|
| 60 |
+
|
| 61 |
+
def _adapt(self, h, dom):
|
| 62 |
+
Dn, Up = self.adapt_down[dom].to(h.dtype), self.adapt_up[dom].to(h.dtype)
|
| 63 |
+
return h + torch.einsum("btr,brd->btd", F.gelu(torch.einsum("btd,bdr->btr", h, Dn)), Up)
|
| 64 |
+
|
| 65 |
+
def forward(self, x, dom, delay, state=None):
|
| 66 |
+
"""chunked SSD. x (B,T,d_in); dom (B,) long; delay (B,) long frames; state: list per layer of (h, B_last, x_last) or None."""
|
| 67 |
+
h, e = self._cond(x, dom, delay); new = []
|
| 68 |
+
for i, (lay, mlp) in enumerate(zip(self.layers, self.mlps)):
|
| 69 |
+
gb = self.film[i](e)[:, None]; h = h * (1 + gb[..., :h.shape[-1]]) + gb[..., h.shape[-1]:]
|
| 70 |
+
st = None if state is None else tuple(_f(t) for t in state[i][:3])
|
| 71 |
+
s, ns = mamba3_layer_ssd_state(lay, _f(h), st, self.chunk)
|
| 72 |
+
h = h + s
|
| 73 |
+
h = h + mlp(h)
|
| 74 |
+
new.append(ns)
|
| 75 |
+
return self.out_norm(self._adapt(h, dom)), new
|
| 76 |
+
|
| 77 |
+
def step(self, x, dom, delay, state=None):
|
| 78 |
+
"""recurrent path over T frames (python loop of the Mamba-3 recurrence; constant cost per frame).
|
| 79 |
+
Same arguments and result as forward(); the state is (h, B_last, x_last, B_last x_last^T) per layer and can be
|
| 80 |
+
continued by forward() (which uses the first three entries) and vice versa."""
|
| 81 |
+
h_all, e = self._cond(x, dom, delay)
|
| 82 |
+
B, T, D = h_all.shape
|
| 83 |
+
st = [None] * len(self.layers) if state is None else list(state)
|
| 84 |
+
gbs = [self.film[i](e) for i in range(len(self.layers))]
|
| 85 |
+
outs = []
|
| 86 |
+
for t in range(T):
|
| 87 |
+
h = h_all[:, t]
|
| 88 |
+
for i, (lay, mlp) in enumerate(zip(self.layers, self.mlps)):
|
| 89 |
+
gb = gbs[i]; h = h * (1 + gb[..., :D]) + gb[..., D:]
|
| 90 |
+
y, st[i] = layer_step(lay, _f(h), st[i])
|
| 91 |
+
h = h + y
|
| 92 |
+
h = h + mlp(h)
|
| 93 |
+
outs.append(h)
|
| 94 |
+
h = torch.stack(outs, 1)
|
| 95 |
+
return self.out_norm(self._adapt(h, dom)), st
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def layer_step(lay, u, state):
|
| 99 |
+
"""one frame of Mamba3Layer (= Mamba3Layer.step with the coefficients computed once). u (B, D) -> (y (B, D), state)."""
|
| 100 |
+
Bsz = u.shape[0]
|
| 101 |
+
nu = lay.norm(u.unsqueeze(1))
|
| 102 |
+
alpha, beta, gamma, Bm, Cm, x, theta = lay._coeffs(nu)
|
| 103 |
+
H, N, P = lay.H, lay.N, lay.P
|
| 104 |
+
bx = Bm[:, 0] @ x[:, 0].transpose(-1, -2) # (B,H,N,P)
|
| 105 |
+
if state is None:
|
| 106 |
+
h = gamma[:, 0, :, None, None] * bx
|
| 107 |
+
else:
|
| 108 |
+
h0, Bl, xl = (_f(t) for t in state[:3])
|
| 109 |
+
prev_bx = _f(state[3]) if len(state) > 3 else Bl @ xl.transpose(-1, -2)
|
| 110 |
+
pre = alpha[:, 0, :, None, None] * h0 + beta[:, 0, :, None, None] * prev_bx
|
| 111 |
+
h = rotate_pairs(pre.transpose(-1, -2), theta[:, 0].unsqueeze(-2).expand(-1, -1, P, -1)).transpose(-1, -2)
|
| 112 |
+
h = h + gamma[:, 0, :, None, None] * bx
|
| 113 |
+
y = (Cm[:, 0].transpose(-1, -2) @ h).reshape(Bsz, -1)
|
| 114 |
+
return lay.gate_and_proj(y, nu[:, 0]), (h, Bm[:, 0], x[:, 0], bx)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------- pooling with carried state
|
| 118 |
+
def pool_speaker(z, spk, carry):
|
| 119 |
+
"""z (B,T,D); spk (B,T) long speaker index (-1 none); carry (sum (B,S,D) fp64, n (B,S) fp64) -> running mean over that
|
| 120 |
+
speaker's frames (incl. earlier calls), new carry."""
|
| 121 |
+
B, T, D = z.shape
|
| 122 |
+
sp = torch.where((spk >= 0) & (spk < S_MAX), spk, torch.full_like(spk, -1))
|
| 123 |
+
key = (torch.arange(B, device=z.device)[:, None] * (S_MAX + 1) + (sp + 1)) * T + torch.arange(T, device=z.device)[None]
|
| 124 |
+
order = torch.argsort(key.reshape(-1), stable=True)
|
| 125 |
+
zs = z.reshape(B * T, D)[order].double(); gs = key.reshape(-1)[order] // T
|
| 126 |
+
cs = torch.cumsum(zs, 0)
|
| 127 |
+
start = torch.ones_like(gs, dtype=torch.bool); start[1:] = gs[1:] != gs[:-1]
|
| 128 |
+
pos = torch.arange(B * T, device=z.device)
|
| 129 |
+
first = torch.cummax(torch.where(start, pos, torch.zeros_like(pos)), 0).values
|
| 130 |
+
base = torch.where(first[:, None] > 0, cs[(first - 1).clamp(min=0)], torch.zeros_like(cs))
|
| 131 |
+
num = torch.empty_like(cs); num[order] = cs - base
|
| 132 |
+
cnt = torch.empty(B * T, dtype=torch.float64, device=z.device); cnt[order] = (pos - first + 1).double()
|
| 133 |
+
num = num.reshape(B, T, D); cnt = cnt.reshape(B, T)
|
| 134 |
+
cs_, cn_ = carry
|
| 135 |
+
sidx = sp.clamp(min=0)
|
| 136 |
+
num = num + torch.gather(cs_, 1, sidx[..., None].expand(B, T, D)); cnt = cnt + torch.gather(cn_, 1, sidx)
|
| 137 |
+
out = (num / cnt.clamp(min=1)[..., None]).float() * (sp >= 0)[..., None].float()
|
| 138 |
+
oh = F.one_hot(sidx, S_MAX).double() * (sp >= 0)[..., None].double()
|
| 139 |
+
new = (cs_ + torch.einsum("bts,btd->bsd", oh, z.double()), cn_ + oh.sum(1))
|
| 140 |
+
return out, new
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def pool_segment(z, starts, carry):
|
| 144 |
+
"""causal mean since the last start (True at a unit's first frame); carry (sum (B,D), n (B,)) continues the open unit.
|
| 145 |
+
starts=None -> one unit for the whole stream (item pooling)."""
|
| 146 |
+
B, T, D = z.shape
|
| 147 |
+
st = torch.zeros(B, T, dtype=torch.bool, device=z.device) if starts is None else starts
|
| 148 |
+
cs = torch.cumsum(z.double(), 1); cs0 = F.pad(cs, (0, 0, 1, 0))[:, :-1]
|
| 149 |
+
t = torch.arange(T, device=z.device)[None].expand(B, T)
|
| 150 |
+
first = torch.cummax(torch.where(st, t, torch.full_like(t, -1)), 1).values
|
| 151 |
+
base = torch.gather(cs0, 1, first.clamp(min=0)[..., None].expand(B, T, D))
|
| 152 |
+
n = (t - first.clamp(min=0) + 1).double()
|
| 153 |
+
open_ = (first < 0)
|
| 154 |
+
num = torch.where(open_[..., None], cs + carry[0][:, None], cs - base)
|
| 155 |
+
n = torch.where(open_, (t + 1).double() + carry[1][:, None], n)
|
| 156 |
+
out = (num / n.clamp(min=1)[..., None]).float()
|
| 157 |
+
last = first[:, -1]
|
| 158 |
+
new_s = torch.where((last < 0)[:, None], carry[0] + cs[:, -1], cs[:, -1] - torch.gather(cs0, 1, last.clamp(min=0)[:, None, None].expand(B, 1, D))[:, 0])
|
| 159 |
+
new_n = torch.where(last < 0, carry[1] + T, (T - last).double())
|
| 160 |
+
return out, (new_s, new_n)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def zero_carry(B, D, device):
|
| 164 |
+
z = lambda *s: torch.zeros(*s, dtype=torch.float64, device=device)
|
| 165 |
+
return dict(utt=(z(B, D), z(B)), speaker=(z(B, S_MAX, D), z(B, S_MAX)), item=(z(B, D), z(B)))
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class TagHead(nn.Module):
|
| 169 |
+
def __init__(self, table, n_text=384, d_model=512, d_k=512):
|
| 170 |
+
super().__init__()
|
| 171 |
+
self.table = table
|
| 172 |
+
self.register_buffer("E", torch.zeros(table.n, n_text)) # frozen tag-text embeddings (from the checkpoint)
|
| 173 |
+
self.register_buffer("valid", torch.as_tensor(table.valid), persistent=False)
|
| 174 |
+
self.P = nn.Linear(n_text, d_k, bias=False)
|
| 175 |
+
self.r = nn.Parameter(torch.zeros(table.n, d_k)); self.b = nn.Parameter(torch.zeros(table.n))
|
| 176 |
+
self.bq = nn.ParameterDict({q: nn.Parameter(torch.zeros(table.n)) for q in QUERIES})
|
| 177 |
+
self.Q = nn.ModuleDict({q: nn.Linear(d_model, d_k) for q in QUERIES})
|
| 178 |
+
self.pool_in = nn.ModuleDict({q: nn.Sequential(nn.Linear(d_model, d_model), nn.GELU(), nn.Linear(d_model, d_model)) for q in POOLED})
|
| 179 |
+
for q in QUERIES:
|
| 180 |
+
self.register_buffer(f"cols_{q}", torch.tensor(table.cols[q], dtype=torch.long), persistent=False)
|
| 181 |
+
|
| 182 |
+
def cols(self, q):
|
| 183 |
+
return getattr(self, f"cols_{q}")
|
| 184 |
+
|
| 185 |
+
def keys(self):
|
| 186 |
+
return self.P(self.E) + self.r
|
| 187 |
+
|
| 188 |
+
def pooled(self, h, spk, ustart, carry, h_utt=None):
|
| 189 |
+
"""h_utt: the word-fused h' for the utterance pool (word channel); speaker / item pools read the audio-only h."""
|
| 190 |
+
zin, new = {}, {}
|
| 191 |
+
for q in POOLED:
|
| 192 |
+
hq = h_utt if (q == "utt" and h_utt is not None) else h
|
| 193 |
+
z = hq + self.pool_in[q](hq)
|
| 194 |
+
if q == "speaker":
|
| 195 |
+
zin[q], new[q] = pool_speaker(z.float(), spk, carry[q])
|
| 196 |
+
elif q == "utt":
|
| 197 |
+
zin[q], new[q] = pool_segment(z.float(), ustart, carry[q])
|
| 198 |
+
else:
|
| 199 |
+
zin[q], new[q] = pool_segment(z.float(), None, carry[q])
|
| 200 |
+
return zin, new
|
| 201 |
+
|
| 202 |
+
def logits_q(self, q, zq, dom, K=None):
|
| 203 |
+
"""raw (prior-free) logits for read-out q; domain-invalid columns -> NEG."""
|
| 204 |
+
K = self.keys() if K is None else K
|
| 205 |
+
c = self.cols(q)
|
| 206 |
+
z = self.Q[q](zq.float()) @ K[c].T + (self.b + self.bq[q])[c]
|
| 207 |
+
return z.masked_fill(~self.valid[dom][:, c][:, None, :], NEG)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ---------------------------------------------------------------- entities: name pointer + numeral value head
|
| 211 |
+
NAME_MAX_BYTES = 40
|
| 212 |
+
C_KINDS = ["person", "team", "organization", "place", "work_product", "event_name", "legal_ref", "game_entity", "game_map", "other"]
|
| 213 |
+
MENTION_KINDS = {"entity.mention.person": [0, 9], "entity.mention.team": [1, 2], "entity.mention.organization": [2, 1, 9],
|
| 214 |
+
"entity.mention.place": [3, 9], "entity.mention.work_product": [4, 9], "entity.mention.event_name": [5, 9],
|
| 215 |
+
"entity.mention.legal_ref": [6, 9], "entity.mention.game_entity": [7, 9], "entity.mention.game_map": [8, 9]}
|
| 216 |
+
K_SLOTS = 4
|
| 217 |
+
CLOCK = 3
|
| 218 |
+
SPAN_G_MULT = 20.0
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def encode_names(names, max_len=NAME_MAX_BYTES, keep="head"):
|
| 222 |
+
import numpy as np
|
| 223 |
+
ids = np.zeros((len(names), max_len), np.int64)
|
| 224 |
+
for i, n in enumerate(names):
|
| 225 |
+
b = " ".join(str(n).lower().split()).encode("utf-8")
|
| 226 |
+
b = b[:max_len] if keep == "head" else b[-max_len:]
|
| 227 |
+
ids[i, :len(b)] = np.frombuffer(b, np.uint8).astype(np.int64) + 1
|
| 228 |
+
return ids
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class NameEncoder(nn.Module):
|
| 232 |
+
"""byte embedding -> 3 residual dilated Conv1d -> [masked mean, max] -> MLP (+ kind embedding), unit norm."""
|
| 233 |
+
def __init__(self, d=128, char_dim=48):
|
| 234 |
+
super().__init__()
|
| 235 |
+
self.d = d
|
| 236 |
+
self.emb = nn.Embedding(257, char_dim, padding_idx=0)
|
| 237 |
+
self.conv_in = nn.Conv1d(char_dim, d, 3, padding=1)
|
| 238 |
+
self.convs = nn.ModuleList([nn.Conv1d(d, d, 3, padding=dl, dilation=dl) for dl in (1, 2, 4)])
|
| 239 |
+
self.kind = nn.Embedding(len(C_KINDS), d)
|
| 240 |
+
self.out = nn.Sequential(nn.Linear(2 * d, d), nn.GELU(), nn.Linear(d, d))
|
| 241 |
+
|
| 242 |
+
def forward(self, ids, kinds):
|
| 243 |
+
sh = ids.shape[:-1]; ids = ids.reshape(-1, ids.shape[-1]); m = (ids > 0).float()[:, None]
|
| 244 |
+
x = self.emb(ids).float().transpose(1, 2); h = F.gelu(self.conv_in(x)) * m
|
| 245 |
+
for cv in self.convs:
|
| 246 |
+
h = h + F.gelu(cv(h)) * m
|
| 247 |
+
mean = h.sum(-1) / m.sum(-1).clamp(min=1.0); mx = h.masked_fill(m == 0, -1e4).max(-1).values * (m.sum(-1) > 0).float()
|
| 248 |
+
e = self.out(torch.cat([mean, mx], -1)) + self.kind(kinds.reshape(-1))
|
| 249 |
+
return F.normalize(e, dim=-1).reshape(*sh, self.d)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class EntityHeads(nn.Module):
|
| 253 |
+
def __init__(self, d_model=512, d_key=512, d=128, n_units=64, span=True, span_new=True, sim=False):
|
| 254 |
+
super().__init__()
|
| 255 |
+
self.names = NameEncoder(d)
|
| 256 |
+
self.query = nn.Sequential(nn.Linear(d_model, d), nn.GELU(), nn.Linear(d, d))
|
| 257 |
+
self.key_in = nn.Linear(d_key, d_model, bias=False)
|
| 258 |
+
self.special = nn.Parameter(torch.zeros(2, d)) # new, generic
|
| 259 |
+
self.special_b = nn.Parameter(torch.zeros(2))
|
| 260 |
+
self.log_scale = nn.Parameter(torch.tensor([math.log(10.0)]))
|
| 261 |
+
self.nwt = nn.Linear(d_model, K_SLOTS * 43)
|
| 262 |
+
self.unit = nn.Linear(d_model, n_units + 1)
|
| 263 |
+
self.clock = nn.Linear(d_model, CLOCK)
|
| 264 |
+
self.approx = nn.Linear(d_model, 2)
|
| 265 |
+
self.n_units = n_units
|
| 266 |
+
self.span_q = nn.Linear(d, d) if span else None
|
| 267 |
+
if span:
|
| 268 |
+
self.span_g = nn.Parameter(torch.zeros(1))
|
| 269 |
+
if span and span_new:
|
| 270 |
+
self.span_new = nn.Parameter(torch.zeros(1))
|
| 271 |
+
self.sim_w = None
|
| 272 |
+
if sim: # 1.4.1: heard-span <-> candidate string / phonetic similarity features (namesim.py), per heard-text source row
|
| 273 |
+
self.sim_w = nn.Parameter(torch.zeros(3, 6)); self.sim_new = nn.Parameter(torch.zeros(3, 3))
|
| 274 |
+
if span:
|
| 275 |
+
self.span_g_src = nn.Parameter(torch.zeros(3))
|
| 276 |
+
|
| 277 |
+
def ptr_logits(self, h, cls_key, E, cmask, Es=None, Sf=None, src=0):
|
| 278 |
+
"""h (B,T,D); cls_key (B,T,Dk); E (B,R,d) candidate name embeddings; cmask (B,T,R) bool; Es (B,T,d) heard-text embedding
|
| 279 |
+
(zeros where no heard text) or None; Sf (B,T,R,6) heard-text similarity features (namesim.sim_feats) or None; src = heard-text
|
| 280 |
+
source row (0 machine-ASR words, 1 the built-in decoder's words) -> (B,T,2+R) logits [new, generic, candidates]."""
|
| 281 |
+
qr = self.query(h.float() + self.key_in(cls_key.float()))
|
| 282 |
+
use_span = Es is not None and self.span_q is not None
|
| 283 |
+
if use_span:
|
| 284 |
+
qr = qr + self.span_q(Es.float())
|
| 285 |
+
q = F.normalize(qr, dim=-1)
|
| 286 |
+
sc = self.log_scale.clamp(max=math.log(50.0)).exp()
|
| 287 |
+
s = torch.einsum("btd,brd->btr", q, E.float()) * sc
|
| 288 |
+
if use_span:
|
| 289 |
+
g_ = self.span_g if getattr(self, "span_g_src", None) is None else self.span_g + self.span_g_src[int(src)]
|
| 290 |
+
s = s + SPAN_G_MULT * g_ * sc * torch.einsum("btd,brd->btr", Es.float(), E.float())
|
| 291 |
+
use_sim = Sf is not None and self.sim_w is not None
|
| 292 |
+
if use_sim:
|
| 293 |
+
Sf = Sf.float()
|
| 294 |
+
s = s + SPAN_G_MULT * sc * torch.einsum("btrf,f->btr", Sf, self.sim_w[int(src)])
|
| 295 |
+
s = s.masked_fill(~cmask, NEG)
|
| 296 |
+
sp = torch.einsum("btd,kd->btk", q, F.normalize(self.special, dim=-1)) * sc + self.special_b
|
| 297 |
+
if use_sim:
|
| 298 |
+
hs = (Sf.abs().sum((-1, -2)) > 0).float()
|
| 299 |
+
mb = Sf[..., 4].masked_fill(~cmask, 0.0).max(-1).values; msh = Sf[..., 5].masked_fill(~cmask, 0.0).max(-1).values
|
| 300 |
+
sn = self.sim_new[int(src)]
|
| 301 |
+
tn = sn[0] + sn[1] * (1.0 - mb) + sn[2] * (1.0 - msh)
|
| 302 |
+
sp = torch.cat([sp[..., :1] + (SPAN_G_MULT * sc * hs * tn)[..., None], sp[..., 1:]], -1)
|
| 303 |
+
if use_span and getattr(self, "span_new", None) is not None:
|
| 304 |
+
ms = torch.einsum("btd,brd->btr", Es.float(), E.float()).masked_fill(~cmask, -1.0).max(-1).values.clamp(min=-1.0)
|
| 305 |
+
has = (Es.float().abs().sum(-1) > 0).float()
|
| 306 |
+
sp = torch.cat([sp[..., :1] + SPAN_G_MULT * self.span_new * sc * has[..., None] * (1.0 - ms[..., None]), sp[..., 1:]], -1)
|
| 307 |
+
return torch.cat([sp, s], -1)
|
| 308 |
+
|
| 309 |
+
def span_embed(self, text):
|
| 310 |
+
"""heard-span text (string) -> (d,) unit embedding through the shared name encoder (kind 0); keeps the most recent bytes."""
|
| 311 |
+
ids = torch.as_tensor(encode_names([text], keep="tail"), device=self.special.device)
|
| 312 |
+
return self.names(ids, torch.zeros(1, dtype=torch.long, device=ids.device))[0]
|
| 313 |
+
|
| 314 |
+
def value_logits(self, h):
|
| 315 |
+
return dict(nwt=self.nwt(h).float().reshape(*h.shape[:-1], K_SLOTS, -1), unit=self.unit(h).float(),
|
| 316 |
+
clock=self.clock(h).float(), approx=self.approx(h).float())
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class BioHead(nn.Module):
|
| 320 |
+
"""non-speech vocal events on the frame read-out (sneeze was never trained: masked in the output)."""
|
| 321 |
+
EV = ("cough", "throat_clear", "breath", "sneeze")
|
| 322 |
+
|
| 323 |
+
def __init__(self, d_model=512):
|
| 324 |
+
super().__init__()
|
| 325 |
+
self.ev = nn.Linear(d_model, len(self.EV))
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class DiarHeads(nn.Module):
|
| 329 |
+
"""arrival-ordered speaker slots ([none, slot 1..K]) + unit-norm voice embedding from h_t and the input frame x_t."""
|
| 330 |
+
def __init__(self, d_model=512, d_in=640, k=8, d=128, hidden=256, n_extra=0):
|
| 331 |
+
super().__init__()
|
| 332 |
+
self.k = k + n_extra
|
| 333 |
+
self.slot = nn.Linear(d_model, 1 + k)
|
| 334 |
+
self.h_proj = nn.Linear(d_model, hidden); self.x_norm = nn.LayerNorm(d_in); self.x_proj = nn.Linear(d_in, hidden)
|
| 335 |
+
self.out = nn.Sequential(nn.GELU(), nn.Linear(hidden, hidden), nn.GELU(), nn.Linear(hidden, d))
|
| 336 |
+
self.slot_ext = nn.Linear(d_model, n_extra) if n_extra else None
|
| 337 |
+
|
| 338 |
+
def slot_logits(self, h):
|
| 339 |
+
z = self.slot(h)
|
| 340 |
+
return torch.cat([z, self.slot_ext(h)], -1) if self.slot_ext is not None else z
|
| 341 |
+
|
| 342 |
+
def embed(self, h, x):
|
| 343 |
+
return F.normalize(self.out(self.h_proj(h.float()) + self.x_proj(self.x_norm(x.float()))), dim=-1)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
class ArcNet(nn.Module):
|
| 347 |
+
def __init__(self, table, cfg):
|
| 348 |
+
super().__init__()
|
| 349 |
+
self.trunk = Trunk(**cfg["trunk"])
|
| 350 |
+
d = self.trunk.cfg["d_model"]
|
| 351 |
+
self.head = TagHead(table, n_text=int(cfg.get("n_text", 384)), d_model=d)
|
| 352 |
+
self.ent = EntityHeads(d_model=d, d_key=d, n_units=int(cfg.get("n_units", 64)), span=bool(cfg.get("ptr_span_text")),
|
| 353 |
+
span_new=bool(cfg.get("ptr_span_new")), sim=bool(cfg.get("ptr_sim_feat")))
|
| 354 |
+
self.diar = DiarHeads(d_model=d, d_in=self.trunk.cfg["d_in"], n_extra=max(0, int(cfg.get("diar_slots", 8)) - 8))
|
| 355 |
+
self.bio = BioHead(d) if cfg.get("bio_head") else None
|
| 356 |
+
self.word = None
|
| 357 |
+
if cfg.get("word_ch"): # 1.4.1 word channel (wordch.py)
|
| 358 |
+
from .wordch import WordChannel
|
| 359 |
+
self.word = WordChannel(d_model=d, d_k=d, n_in=len(table.cols["span_in"]), k_slots=K_SLOTS, n_nwt=int(cfg.get("n_nwt", 43)))
|
| 360 |
+
self.cfg = cfg
|
| 361 |
+
|
| 362 |
+
def trunk_run(self, x, dom, delay, state=None, recurrent=False):
|
| 363 |
+
return (self.trunk.step if recurrent else self.trunk)(x, dom, delay, state)
|
arctag/_core/arc_audio/ontology.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tag table (tags.json in the model repo): stable tag ids and order, exclusive groups, read-outs, domain validity.
|
| 2 |
+
|
| 3 |
+
Read-out per tag unit: F/G -> frame, W -> span_in + span_end, U/T -> utt, S -> speaker, E -> ev_at | ev_rec (.recent) |
|
| 4 |
+
ev_imm (.imminent), D -> item.
|
| 5 |
+
"""
|
| 6 |
+
import hashlib, json
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
FINE_DOMAINS = ["nba", "nhl", "nfl", "cricket", "soccer", "esports", "news", "talk", "stream_chat", "meeting", "court_appellate",
|
| 10 |
+
"court_trial", "assistant", "assistant_noisy", "short_clips", "unknown"]
|
| 11 |
+
QUERIES = ("frame", "span_in", "span_end", "utt", "speaker", "ev_at", "ev_imm", "ev_rec", "item")
|
| 12 |
+
POOLED = ("utt", "speaker", "item")
|
| 13 |
+
SPORTS = ("nba", "nhl", "nfl", "cricket", "soccer")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def readouts(t):
|
| 17 |
+
u = t["unit"]
|
| 18 |
+
if u in ("F", "G"):
|
| 19 |
+
return ("frame",)
|
| 20 |
+
if u == "W":
|
| 21 |
+
return ("span_in", "span_end")
|
| 22 |
+
if u in ("U", "T"):
|
| 23 |
+
return ("utt",)
|
| 24 |
+
if u == "S":
|
| 25 |
+
return ("speaker",)
|
| 26 |
+
if u == "D":
|
| 27 |
+
return ("item",)
|
| 28 |
+
if u == "E":
|
| 29 |
+
return ("ev_rec",) if t["id"].endswith(".recent") else ("ev_imm",) if t["id"].endswith(".imminent") else ("ev_at",)
|
| 30 |
+
raise ValueError(f"unknown unit {u} for {t['id']}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def domain_index(domain):
|
| 34 |
+
d = (domain or "unknown").lower()
|
| 35 |
+
if d not in FINE_DOMAINS:
|
| 36 |
+
raise ValueError(f"unknown domain {domain!r}; use one of {FINE_DOMAINS}")
|
| 37 |
+
return FINE_DOMAINS.index(d)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Table:
|
| 41 |
+
def __init__(self, path):
|
| 42 |
+
raw = open(path, "rb").read(); self.sha = hashlib.sha256(raw).hexdigest()
|
| 43 |
+
j = json.loads(raw); self.version = j["version"]; self.j = j
|
| 44 |
+
tags = sorted(j["tags"], key=lambda t: t["index"])
|
| 45 |
+
self.n = max(t["index"] for t in tags) + 1
|
| 46 |
+
self.tag = [None] * self.n
|
| 47 |
+
for t in tags:
|
| 48 |
+
self.tag[t["index"]] = t
|
| 49 |
+
self.ids = [t["id"] if t else f"_unused{i}" for i, t in enumerate(self.tag)]
|
| 50 |
+
self.idx = {t: i for i, t in enumerate(self.ids)}
|
| 51 |
+
rg = {g["id"]: g for g in j["groups"]}
|
| 52 |
+
self.groups, self.members = [], {}
|
| 53 |
+
for t in tags:
|
| 54 |
+
if t.get("exclusivity") not in (None, "independent"):
|
| 55 |
+
if t["group"] not in self.members:
|
| 56 |
+
self.groups.append(t["group"]); self.members[t["group"]] = []
|
| 57 |
+
self.members[t["group"]].append(t["index"])
|
| 58 |
+
self.group_of = {i: g for g, m in self.members.items() for i in m}
|
| 59 |
+
self.gate = {g: rg.get(g, {}).get("gate") for g in self.groups}
|
| 60 |
+
self.group_info = rg
|
| 61 |
+
self.trained = [i for i, t in enumerate(self.tag) if t and t.get("status") not in ("reserved", "derived")]
|
| 62 |
+
dg = j["domains"]
|
| 63 |
+
V = np.zeros((len(FINE_DOMAINS), self.n), bool)
|
| 64 |
+
for i in self.trained:
|
| 65 |
+
keys = self.tag[i]["domains"]
|
| 66 |
+
for d, fd in enumerate(FINE_DOMAINS):
|
| 67 |
+
V[d, i] = any(k == "*" or k == fd or fd in dg.get(k, []) for k in keys)
|
| 68 |
+
# 'unknown' (no domain given): every tag that is valid in some domain (the trunk still gets the 'unknown' domain tag)
|
| 69 |
+
V[FINE_DOMAINS.index("unknown")] |= V.any(0)
|
| 70 |
+
self.valid = V
|
| 71 |
+
self.ro = {i: readouts(self.tag[i]) for i in self.trained}
|
| 72 |
+
self.cols = {q: [i for i in self.trained if q in self.ro[i]] for q in QUERIES}
|
| 73 |
+
self.facet = {i: self.tag[i]["facet"] for i in self.trained}
|
| 74 |
+
self.status = {i: self.tag[i]["status"] for i in self.trained}
|
| 75 |
+
|
| 76 |
+
def describe(self, tag_id):
|
| 77 |
+
t = self.tag[self.idx[tag_id]]
|
| 78 |
+
return dict(id=t["id"], facet=t["facet"], unit=t["unit"], group=t.get("group"), description=t.get("description"),
|
| 79 |
+
paraphrases=t.get("paraphrases") or [], status=t.get("status"))
|
arctag/_core/arc_audio/wordch.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc 1.4.1 word channel (inference): heard transcript words feed the network next to the audio.
|
| 2 |
+
|
| 3 |
+
Each heard word j (any ASR: the built-in decoder, or external words) becomes a vector u_j:
|
| 4 |
+
e_j = LN(W1 mean_p(E[piece] + pos_p) + W2 E[last piece]) + flag / source embeddings (E = the shipped decoder's frozen
|
| 5 |
+
piece-embedding table; pieces of the package tokenizer, first 7 + last)
|
| 6 |
+
x_j = e_j + Wa [mean of the trunk state h over the word's own frames, h at its last own frame] + has-audio embedding
|
| 7 |
+
u_j = 2-layer causal LOCAL transformer over the words (window 32 words, RoPE) -> receptive field 62 words back.
|
| 8 |
+
Each label frame t then reads a window of up to 8 words around the newest available word (6 back, 2 ahead when available):
|
| 9 |
+
c_t = Wo attn(q = h_t, k = u_j + rel(t - f0_j) + rel(t - oe_j), v = u_j) (0 when no word is available)
|
| 10 |
+
h'_t = h_t + c_t feeds the frame / span / event read-outs, the utterance pool and the name / number heads;
|
| 11 |
+
the speaker / item pools, diarization and vocal-event heads read the audio-only h_t (as trained).
|
| 12 |
+
With no words, h' == h exactly (the audio-only network).
|
| 13 |
+
Word-aligned read-out (war_*): per-word B / I probabilities of every span tag (right context k words) + per-word numeral tokens.
|
| 14 |
+
"""
|
| 15 |
+
import math, re
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch, torch.nn as nn, torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
P_MAX = 8 # pieces per word (first 7 + last when longer)
|
| 20 |
+
K_CTX = 8 # words per frame window
|
| 21 |
+
BACK = 6
|
| 22 |
+
ENC_WIN = 32 # WordEnc causal window (words)
|
| 23 |
+
N_ENC = 2 # WordEnc layers
|
| 24 |
+
RF = (ENC_WIN - 1) * N_ENC # receptive field in words (62)
|
| 25 |
+
OFF_CLIP = 32
|
| 26 |
+
SRC_ROW = dict(npz=0, ms=1, corrupt=2, none=3) # 0 = machine-ASR words (external ASR), 1 = the shipped (built-in) decoder's words
|
| 27 |
+
_PUNCT = re.compile(r"^[^\w]+|[^\w]+$")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def norm_word(w):
|
| 31 |
+
return _PUNCT.sub("", " ".join(str(w).lower().split()))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def own_ends(f0, f1):
|
| 35 |
+
f0 = np.asarray(f0, np.int64); f1 = np.asarray(f1, np.int64); oe = f1.copy()
|
| 36 |
+
nxt = np.r_[f0[1:], np.iinfo(np.int64).max]
|
| 37 |
+
m = (f0 < nxt) & (nxt <= f1)
|
| 38 |
+
oe[m] = nxt[m] - 1
|
| 39 |
+
return np.maximum(oe, f0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def utt_ends(f0, oe, gap=4):
|
| 43 |
+
W = len(f0)
|
| 44 |
+
if not W:
|
| 45 |
+
return np.zeros(0, np.int64)
|
| 46 |
+
brk = np.r_[f0[1:] - oe[:-1] > gap, True]
|
| 47 |
+
ue = np.where(brk, oe, np.iinfo(np.int64).max)
|
| 48 |
+
return np.minimum.accumulate(ue[::-1])[::-1]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def rule_arrival(f0, oe, d, mode="final", Lf=15, L=8):
|
| 52 |
+
"""offline word arrival (the trained / evaluated rule): a final transcript reaches Arc Lf frames after its utterance ends
|
| 53 |
+
(0.4 s pause rule), never before the word's own end + d; monotone. -> first label frame at which word j is readable."""
|
| 54 |
+
if not len(f0):
|
| 55 |
+
return np.zeros(0, np.int64)
|
| 56 |
+
a = (oe + L) if mode == "partial" else (utt_ends(f0, oe) + Lf)
|
| 57 |
+
a = np.maximum(a, oe + d)
|
| 58 |
+
return np.maximum.accumulate(a) - d
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Pieces:
|
| 62 |
+
"""word -> piece ids of the package tokenizer (cached)."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, tok_path):
|
| 65 |
+
from tokenizers import Tokenizer
|
| 66 |
+
self.t = Tokenizer.from_file(tok_path); self.c = {}
|
| 67 |
+
|
| 68 |
+
def __call__(self, w):
|
| 69 |
+
if w not in self.c:
|
| 70 |
+
ids = self.t.encode(w, add_special_tokens=False).ids if w else []
|
| 71 |
+
if len(ids) > P_MAX:
|
| 72 |
+
ids = ids[:P_MAX - 1] + ids[-1:]
|
| 73 |
+
if len(self.c) > 200000:
|
| 74 |
+
self.c.clear()
|
| 75 |
+
self.c[w] = ids
|
| 76 |
+
return self.c[w]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def word_rows(texts, f0, oe, src_row, pieces, prev_oe=None):
|
| 80 |
+
"""-> tok (W,P), npc (W,), flags (W,5) int64 (training row_words features). prev_oe: own end of the word before the first
|
| 81 |
+
one (incremental encoding), for the gap flag."""
|
| 82 |
+
W = len(texts); tok = np.zeros((W, P_MAX), np.int64); npc = np.zeros(W, np.int64); flags = np.zeros((W, 5), np.int64)
|
| 83 |
+
for i, w in enumerate(texts):
|
| 84 |
+
p = pieces(w); npc[i] = len(p); tok[i, :len(p)] = p
|
| 85 |
+
hd = any(ch.isdigit() for ch in w)
|
| 86 |
+
flags[i, 0] = int(hd); flags[i, 1] = int(hd and w.isdigit())
|
| 87 |
+
flags[:, 2] = np.clip(npc, 0, P_MAX)
|
| 88 |
+
if W:
|
| 89 |
+
flags[:, 3] = np.clip(oe - f0 + 1, 1, 20)
|
| 90 |
+
flags[:, 4] = np.clip(np.r_[20 if prev_oe is None else f0[0] - prev_oe - 1, f0[1:] - oe[:-1] - 1], 0, 20)
|
| 91 |
+
return tok, npc, flags
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def frame_window(f0, avail, t0, t1, K=K_CTX, back=BACK):
|
| 95 |
+
"""(t1-t0, K) word indices (-1 none) read by frames t0..t1-1; avail monotone (available words are a prefix)."""
|
| 96 |
+
t = np.arange(t0, t1)
|
| 97 |
+
n = np.searchsorted(avail, t, side="right")
|
| 98 |
+
p = np.searchsorted(f0, t, side="right")
|
| 99 |
+
q = np.minimum(p, n)
|
| 100 |
+
idx = q[:, None] - back + np.arange(K)[None]
|
| 101 |
+
ok = (idx >= 0) & (idx < n[:, None])
|
| 102 |
+
return np.where(ok, idx, -1)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _rope(x, pos):
|
| 106 |
+
dh = x.shape[-1]; inv = 1.0 / (10000 ** (torch.arange(0, dh, 2, device=x.device, dtype=torch.float32) / dh))
|
| 107 |
+
ang = pos.float()[..., None] * inv
|
| 108 |
+
while ang.dim() < x.dim():
|
| 109 |
+
ang = ang.unsqueeze(1)
|
| 110 |
+
c, s_ = torch.cos(ang), torch.sin(ang); x1, x2 = x[..., 0::2], x[..., 1::2]
|
| 111 |
+
return torch.stack([x1 * c - x2 * s_, x1 * s_ + x2 * c], -1).flatten(-2)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class EncLayer(nn.Module):
|
| 115 |
+
"""pre-norm causal local self-attention (window ENC_WIN words incl. self) + FFN (blocked, as trained)."""
|
| 116 |
+
|
| 117 |
+
def __init__(self, d=256, h=4, ff=512, win=ENC_WIN, dropout=0.1):
|
| 118 |
+
super().__init__()
|
| 119 |
+
self.h, self.win = h, win
|
| 120 |
+
self.n1 = nn.LayerNorm(d); self.qkv = nn.Linear(d, 3 * d); self.o = nn.Linear(d, d)
|
| 121 |
+
self.n2 = nn.LayerNorm(d); self.ff = nn.Sequential(nn.Linear(d, ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(ff, d)); self.drop = nn.Dropout(dropout)
|
| 122 |
+
|
| 123 |
+
def forward(self, x, valid, pos0=0):
|
| 124 |
+
Bn, W, D = x.shape; h, w = self.h, self.win; dh = D // h
|
| 125 |
+
q, k, v = self.qkv(self.n1(x)).float().view(Bn, W, 3, h, dh).unbind(2)
|
| 126 |
+
pos = (torch.arange(W, device=x.device) + pos0)[None].expand(Bn, W)
|
| 127 |
+
q = _rope(q.transpose(1, 2), pos); k = _rope(k.transpose(1, 2), pos); v = v.transpose(1, 2)
|
| 128 |
+
nb = (W + w - 1) // w; Wp = nb * w; pad = Wp - W
|
| 129 |
+
if pad:
|
| 130 |
+
q, k, v = (F.pad(t_, (0, 0, 0, pad)) for t_ in (q, k, v))
|
| 131 |
+
vm = F.pad(valid, (0, pad)) if pad else valid
|
| 132 |
+
kp = F.pad(k, (0, 0, w, 0)).view(Bn, h, nb + 1, w, dh); vp = F.pad(v, (0, 0, w, 0)).view(Bn, h, nb + 1, w, dh)
|
| 133 |
+
kb = torch.cat([kp[:, :, :-1], kp[:, :, 1:]], 3); vb = torch.cat([vp[:, :, :-1], vp[:, :, 1:]], 3)
|
| 134 |
+
qb = q.view(Bn, h, nb, w, dh)
|
| 135 |
+
qi = (torch.arange(nb, device=x.device)[:, None] * w + torch.arange(w, device=x.device)[None])
|
| 136 |
+
ki = (torch.arange(nb, device=x.device)[:, None] * w - w + torch.arange(2 * w, device=x.device)[None])
|
| 137 |
+
mp = F.pad(vm, (w, 0)).view(Bn, nb + 1, w); km = torch.cat([mp[:, :-1], mp[:, 1:]], 2)
|
| 138 |
+
m = (ki[:, None, :] <= qi[:, :, None]) & (ki[:, None, :] > qi[:, :, None] - w) & (ki[:, None, :] >= 0)
|
| 139 |
+
m = m[None] & km[:, :, None, :]
|
| 140 |
+
m = m | (ki[:, None, :] == qi[:, :, None])[None]
|
| 141 |
+
a = F.scaled_dot_product_attention(qb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, w, dh),
|
| 142 |
+
kb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, 2 * w, dh), vb.permute(0, 2, 1, 3, 4).reshape(Bn * nb, h, 2 * w, dh),
|
| 143 |
+
attn_mask=m.reshape(Bn * nb, 1, w, 2 * w))
|
| 144 |
+
a = a.view(Bn, nb, h, w, dh).permute(0, 2, 1, 3, 4).reshape(Bn, h, Wp, dh)[:, :, :W].transpose(1, 2).reshape(Bn, W, D)
|
| 145 |
+
x = x + self.drop(self.o(a.to(x.dtype)))
|
| 146 |
+
return x + self.drop(self.ff(self.n2(x)))
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class WordChannel(nn.Module):
|
| 150 |
+
def __init__(self, d_model=512, d_k=512, n_in=41, k_slots=4, n_nwt=43, d_w=256, d_c=128, heads_c=4, n_tok=32768, d_e=320):
|
| 151 |
+
super().__init__()
|
| 152 |
+
self.register_buffer("tok_E", torch.zeros(n_tok, d_e, dtype=torch.float16)) # frozen decoder piece table (from the weights)
|
| 153 |
+
self.ppos = nn.Parameter(torch.zeros(P_MAX, d_e))
|
| 154 |
+
self.W1 = nn.Linear(d_e, d_w); self.W2 = nn.Linear(d_e, d_w, bias=False); self.ln = nn.LayerNorm(d_w)
|
| 155 |
+
self.fl = nn.ModuleList([nn.Embedding(n, d_w) for n in (2, 2, P_MAX + 1, 21, 21)]); self.src = nn.Embedding(len(SRC_ROW) + 1, d_w)
|
| 156 |
+
self.Wa = nn.Linear(2 * d_model, d_w)
|
| 157 |
+
self.hav = nn.Embedding(2, d_w)
|
| 158 |
+
self.enc = nn.ModuleList([EncLayer(d_w) for _ in range(N_ENC)]); self.enc_norm = nn.LayerNorm(d_w)
|
| 159 |
+
self.hc = heads_c; self.fq = nn.Linear(d_model, d_c); self.fk = nn.Linear(d_w, d_c); self.fv = nn.Linear(d_w, d_c)
|
| 160 |
+
self.R0 = nn.Embedding(2 * OFF_CLIP + 1, d_c); self.R1 = nn.Embedding(2 * OFF_CLIP + 1, d_c)
|
| 161 |
+
self.fo = nn.Linear(d_c, d_model)
|
| 162 |
+
self.war_r = nn.Linear(d_w, d_w); self.war_k = nn.Embedding(3, d_w); self.war_ln = nn.LayerNorm(d_w)
|
| 163 |
+
self.QB = nn.Linear(d_w, d_k); self.QI = nn.Linear(d_w, d_k)
|
| 164 |
+
self.bB = nn.Parameter(torch.zeros(n_in)); self.bI = nn.Parameter(torch.zeros(n_in))
|
| 165 |
+
self.nwt = nn.Linear(d_w, k_slots * n_nwt); self.k_slots, self.n_nwt = k_slots, n_nwt
|
| 166 |
+
|
| 167 |
+
def embed_in(self, tok, npc, flags, src, hm, he, have):
|
| 168 |
+
"""per-word encoder input x_j (B,W,d_w). hm / he (B,W,D) own-frame mean / last own-frame trunk state, have (B,W) bool."""
|
| 169 |
+
Et = self.tok_E[tok].float()
|
| 170 |
+
pm = (torch.arange(P_MAX, device=tok.device)[None, None] < npc[..., None]).float()[..., None]
|
| 171 |
+
mean = ((Et + self.ppos) * pm).sum(2) / npc.clamp(min=1)[..., None].float()
|
| 172 |
+
last = torch.gather(Et, 2, (npc - 1).clamp(min=0)[..., None, None].expand(-1, -1, 1, Et.shape[-1]))[:, :, 0]
|
| 173 |
+
e = self.ln(self.W1(mean) + self.W2(last))
|
| 174 |
+
for i, m in enumerate(self.fl):
|
| 175 |
+
e = e + m(flags[..., i].clamp(0, m.num_embeddings - 1))
|
| 176 |
+
e = e + self.src(src.clamp(0, self.src.num_embeddings - 1))
|
| 177 |
+
return e + self.Wa(torch.cat([hm, he], -1)) + self.hav(have.long())
|
| 178 |
+
|
| 179 |
+
def run_enc(self, x, valid, pos0=0):
|
| 180 |
+
for lay in self.enc:
|
| 181 |
+
x = lay(x, valid, pos0)
|
| 182 |
+
return self.enc_norm(x) * valid[..., None].float()
|
| 183 |
+
|
| 184 |
+
def frame_ctx(self, h, u, f0, oe, wctx, t0=0):
|
| 185 |
+
"""h (B,T,D) trunk states of frames t0..t0+T-1; u (B,W,d_w); f0 / oe (B,W) word frames; wctx (B,T,K) word indices
|
| 186 |
+
(-1 none) -> c (B,T,D) (0 where the window is empty)."""
|
| 187 |
+
Bn, T, Kc = wctx.shape; ok = wctx >= 0; idx = wctx.clamp(min=0)
|
| 188 |
+
dc = self.fk.out_features; hh = self.hc; dh = dc // hh
|
| 189 |
+
flat = idx.reshape(Bn, T * Kc)
|
| 190 |
+
gk = torch.gather(self.fk(u), 1, flat[..., None].expand(-1, -1, dc)).view(Bn, T, Kc, dc)
|
| 191 |
+
gv = torch.gather(self.fv(u), 1, flat[..., None].expand(-1, -1, dc)).view(Bn, T, Kc, dc)
|
| 192 |
+
t = torch.arange(t0, t0 + T, device=h.device)[None, :, None]
|
| 193 |
+
o0 = (t - torch.gather(f0, 1, flat).view(Bn, T, Kc)).clamp(-OFF_CLIP, OFF_CLIP) + OFF_CLIP
|
| 194 |
+
o1 = (t - torch.gather(oe, 1, flat).view(Bn, T, Kc)).clamp(-OFF_CLIP, OFF_CLIP) + OFF_CLIP
|
| 195 |
+
gk = gk + self.R0(o0) + self.R1(o1)
|
| 196 |
+
q = self.fq(h).view(Bn, T, 1, hh, dh)
|
| 197 |
+
sc = (q * gk.view(Bn, T, Kc, hh, dh)).sum(-1).float() / math.sqrt(dh)
|
| 198 |
+
sc = sc.masked_fill(~ok[..., None], float("-inf"))
|
| 199 |
+
any_ = ok.any(-1)
|
| 200 |
+
att = torch.softmax(sc, 2).nan_to_num(0.0)
|
| 201 |
+
ctx = (att[..., None] * gv.view(Bn, T, Kc, hh, dh).float()).sum(2).reshape(Bn, T, dc)
|
| 202 |
+
return self.fo(ctx.to(h.dtype)) * any_[..., None].to(h.dtype)
|
| 203 |
+
|
| 204 |
+
def war_logits(self, u, valid, K_in, k):
|
| 205 |
+
Bn, W, D = u.shape
|
| 206 |
+
if k:
|
| 207 |
+
us = F.pad(u[:, k:], (0, 0, 0, k)); ok = F.pad(valid[:, k:], (0, k)) & valid
|
| 208 |
+
else:
|
| 209 |
+
us = torch.zeros_like(u); ok = valid
|
| 210 |
+
v = self.war_ln(u + self.war_r(us) + self.war_k.weight[k])
|
| 211 |
+
return self.QB(v).float() @ K_in.T + self.bB, self.QI(v).float() @ K_in.T + self.bI, ok, v
|
| 212 |
+
|
| 213 |
+
@torch.no_grad()
|
| 214 |
+
def war_probs(self, u, valid, K_in, k=1):
|
| 215 |
+
"""-> P(B), P(I) (B,W,n_in) at right context k (falls back to k-1 / 0 where the right words do not exist yet), v."""
|
| 216 |
+
zB, zI, ok, v = self.war_logits(u, valid, K_in, k)
|
| 217 |
+
if k:
|
| 218 |
+
zB0, zI0, _, _ = self.war_logits(u, valid, K_in, k - 1) if k > 1 else self.war_logits(u, valid, K_in, 0)
|
| 219 |
+
zB = torch.where(ok[..., None], zB, zB0); zI = torch.where(ok[..., None], zI, zI0)
|
| 220 |
+
p = torch.softmax(torch.stack([torch.zeros_like(zB), zB, zI], -1), -1)
|
| 221 |
+
return p[..., 1], p[..., 2], v
|
| 222 |
+
|
| 223 |
+
def nwt_logits(self, v):
|
| 224 |
+
return self.nwt(v).float().reshape(*v.shape[:-1], self.k_slots, self.n_nwt)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class WordFeed:
|
| 228 |
+
"""per-stream word-channel state: the word list (append-only; a revision of earlier words re-encodes from there), each word's
|
| 229 |
+
encoder input / output, its frames and the first label frame at which it is readable."""
|
| 230 |
+
|
| 231 |
+
def __init__(self, wch, pieces, dev, d):
|
| 232 |
+
self.w, self.P, self.dev, self.d = wch, pieces, dev, int(d)
|
| 233 |
+
self.keys = [] # (start, end, text) identity of each word in the list
|
| 234 |
+
self.txt, self.f0, self.oe, self.f1, self.src, self.av = [], [], [], [], [], []
|
| 235 |
+
self.X = None; self.U = None # (n_x, d_w) inputs / (n_u, d_w) outputs (prefix of the list)
|
| 236 |
+
self.n_x = 0
|
| 237 |
+
self.ver = None
|
| 238 |
+
|
| 239 |
+
def __len__(self):
|
| 240 |
+
return len(self.keys)
|
| 241 |
+
|
| 242 |
+
def sync(self, finals, f_now, arrival=None, src_of=None):
|
| 243 |
+
"""bring the list in line with the store's final words. New words become readable at max(f_now, own end) (live) or at
|
| 244 |
+
`arrival[j]` (offline rule). -> True if the list changed."""
|
| 245 |
+
ws = [w for w in finals if norm_word(w["w"])]
|
| 246 |
+
keys = [(w["start"], w["end"], w["w"]) for w in ws]
|
| 247 |
+
n0 = 0; m = min(len(keys), len(self.keys))
|
| 248 |
+
while n0 < m and keys[n0] == self.keys[n0]:
|
| 249 |
+
n0 += 1
|
| 250 |
+
if n0 == len(keys) == len(self.keys):
|
| 251 |
+
return False
|
| 252 |
+
# truncate at the first difference, then append
|
| 253 |
+
for L in (self.keys, self.txt, self.f0, self.oe, self.f1, self.src, self.av):
|
| 254 |
+
del L[n0:]
|
| 255 |
+
self.n_x = min(self.n_x, n0)
|
| 256 |
+
if self.X is not None:
|
| 257 |
+
self.X = self.X[:self.n_x]
|
| 258 |
+
if self.U is not None:
|
| 259 |
+
self.U = self.U[:min(len(self.U), n0)]
|
| 260 |
+
for j in range(n0, len(ws)):
|
| 261 |
+
w = ws[j]
|
| 262 |
+
self.keys.append(keys[j]); self.txt.append(norm_word(w["w"])); self.f0.append(int(w["f0"])); self.f1.append(int(w["f1"]))
|
| 263 |
+
self.src.append(src_of(w) if src_of else SRC_ROW["ms"]); self.av.append(None)
|
| 264 |
+
f0 = np.asarray(self.f0, np.int64); oe = own_ends(f0, np.asarray(self.f1, np.int64))
|
| 265 |
+
# a word already encoded keeps the own end it was encoded with (a later word sharing its last frame does not re-open it)
|
| 266 |
+
old = self.oe[:min(n0, self.n_x)]
|
| 267 |
+
self.oe = [int(x) for x in oe]
|
| 268 |
+
self.oe[:len(old)] = old
|
| 269 |
+
if arrival is not None:
|
| 270 |
+
self.av = [int(x) for x in arrival]
|
| 271 |
+
else:
|
| 272 |
+
for j in range(n0, len(self.keys)):
|
| 273 |
+
a = max(int(f_now), self.oe[j])
|
| 274 |
+
if j:
|
| 275 |
+
a = max(a, self.av[j - 1])
|
| 276 |
+
self.av[j] = a
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
@torch.no_grad()
|
| 280 |
+
def encode_upto(self, f_done, h_at, f_start=None, settle=4):
|
| 281 |
+
"""encode every word whose own frames are all decided (oe < f_done). A word's own end depends on the NEXT word's start (a
|
| 282 |
+
shared frame belongs to the next word, as in the training tables), so the newest word is encoded only once a later word
|
| 283 |
+
is known or `settle` frames have passed after its last frame (no word can still start inside it). Words encoded in this
|
| 284 |
+
call are readable from f_start (the first frame of this feed) at the earliest. h_at(frames) -> (n, D) audio trunk states."""
|
| 285 |
+
n_new = 0
|
| 286 |
+
while self.n_x + n_new < len(self.keys) and self.oe[self.n_x + n_new] < f_done:
|
| 287 |
+
k = self.n_x + n_new
|
| 288 |
+
if k + 1 >= len(self.keys) and f_done <= self.f1[k] + settle:
|
| 289 |
+
break
|
| 290 |
+
n_new += 1
|
| 291 |
+
if not n_new:
|
| 292 |
+
return
|
| 293 |
+
if f_start is not None:
|
| 294 |
+
for k in range(self.n_x, len(self.av)):
|
| 295 |
+
lo_ = f_start if k < self.n_x + n_new else self.av[k - 1]
|
| 296 |
+
self.av[k] = max(self.av[k], lo_, self.av[k - 1] if k else lo_)
|
| 297 |
+
J = list(range(self.n_x, self.n_x + n_new))
|
| 298 |
+
f0 = np.asarray([self.f0[j] for j in J]); oe = np.asarray([self.oe[j] for j in J])
|
| 299 |
+
tok, npc, flags = word_rows([self.txt[j] for j in J], f0, oe, None, self.P, prev_oe=self.oe[J[0] - 1] if J[0] > 0 else None)
|
| 300 |
+
hm, he = [], []
|
| 301 |
+
for a, b in zip(f0, oe):
|
| 302 |
+
H = h_at(list(range(int(a), int(b) + 1))).float()
|
| 303 |
+
hm.append(H.mean(0)); he.append(H[-1])
|
| 304 |
+
T = lambda a, dt=torch.long: torch.as_tensor(np.asarray(a), device=self.dev, dtype=dt)[None]
|
| 305 |
+
x = self.w.embed_in(T(tok), T(npc), T(flags), T([self.src[j] for j in J]), torch.stack(hm)[None], torch.stack(he)[None],
|
| 306 |
+
torch.ones(1, n_new, dtype=torch.bool, device=self.dev))[0]
|
| 307 |
+
self.X = x if self.X is None or not len(self.X) else torch.cat([self.X, x], 0)
|
| 308 |
+
self.n_x += n_new
|
| 309 |
+
# outputs for words [n_u, n_x): run the encoder over the receptive field before them
|
| 310 |
+
n_u = 0 if self.U is None else len(self.U)
|
| 311 |
+
lo = max(0, n_u - RF)
|
| 312 |
+
xs = self.X[lo:self.n_x][None]
|
| 313 |
+
u = self.w.run_enc(xs, torch.ones(xs.shape[:2], dtype=torch.bool, device=self.dev), pos0=lo)[0][n_u - lo:]
|
| 314 |
+
self.U = u if self.U is None or not len(self.U) else torch.cat([self.U, u], 0)
|
| 315 |
+
|
| 316 |
+
@torch.no_grad()
|
| 317 |
+
def fuse(self, h, f_start):
|
| 318 |
+
"""h (T, D) audio trunk states of frames f_start..; -> h' = h + c (T, D)."""
|
| 319 |
+
T = h.shape[0]; n_u = 0 if self.U is None else len(self.U)
|
| 320 |
+
if not n_u:
|
| 321 |
+
return h
|
| 322 |
+
f0 = np.asarray(self.f0[:n_u], np.int64); av = np.asarray(self.av[:n_u], np.int64)
|
| 323 |
+
idx = frame_window(f0, av, f_start, f_start + T)
|
| 324 |
+
if (idx < 0).all():
|
| 325 |
+
return h
|
| 326 |
+
lo = int(idx[idx >= 0].min()); hi = int(idx.max()) + 1
|
| 327 |
+
loc = np.where(idx >= 0, idx - lo, -1)
|
| 328 |
+
dv = self.dev
|
| 329 |
+
u = self.U[lo:hi][None]
|
| 330 |
+
F0 = torch.as_tensor(f0[lo:hi], device=dv)[None]; OE = torch.as_tensor(np.asarray(self.oe[lo:hi], np.int64), device=dv)[None]
|
| 331 |
+
c = self.w.frame_ctx(h[None], u, F0, OE, torch.as_tensor(loc, device=dv)[None], t0=f_start)[0]
|
| 332 |
+
return h + c.to(h.dtype)
|
arctag/_core/arc_text.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc Tagger = the Arc of Arc Transcriber (arc_audio 1.4.2 package, PK8a weights) with the audio path removed: the Arc's own word
|
| 2 |
+
channel is the input. arc_audio is imported read-only from the PK8a trainer's package (pk_config.json "arc_pkg" =
|
| 3 |
+
release_staging/arc_v142); nothing in it is edited.
|
| 4 |
+
|
| 5 |
+
per word j (one trunk step per word, no audio frames):
|
| 6 |
+
e_j = arc.word.embed_in(pieces, flags, src, hm=0, he=0, have=0) the word channel's input with "no audio" (exactly the
|
| 7 |
+
Arc's no-audio path): frozen 32768 x 320 piece table tok_E (moonshine-streaming-tiny tokenizer.json, = Pieces), first
|
| 8 |
+
7 + last piece, flags (has digit, all digits, n pieces, duration, gap in 100 ms frames), word source row
|
| 9 |
+
+ zero-init provider features (gap / duration buckets, final / interim, confidence, speaker change, asr_source)
|
| 10 |
+
u_j = arc.word.run_enc(e) 2 causal local self-attention layers (32-word window)
|
| 11 |
+
x_j = inp(in_norm(u_j)) NEW 256 -> 512 input projection replacing the audio one
|
| 12 |
+
(inp init = word.fo . word.fv, the value path by which the
|
| 13 |
+
Arc adds a word to its frame state)
|
| 14 |
+
h_j = arc.trunk (5 x [FiLM -> Mamba-3 -> MLP], domain adapters) chunked SSD in training, Trunk.step + carried state streaming
|
| 15 |
+
read-outs: arc.head TagHead on h (definition keys, pooled utt / speaker / item with the Arc carry); span_beg (the B of BIO) =
|
| 16 |
+
arc.word.war_* B read-out on u with right context k (T0 k=0, T1/T2 k<=2); entity KB match = arc.ent.ptr_logits (NameEncoder
|
| 17 |
+
candidates, [new, generic, KB...]); latency tiers = delayed read-outs of the same causal pass (word i read at i+d with a delay
|
| 18 |
+
embedding, T1 d<=3, T2 d = to the utterance end <= 8).
|
| 19 |
+
dropped: arc.diar (voice embedding needs audio), arc.bio (vocal events), trunk.inp/in_norm audio 640-d projection.
|
| 20 |
+
"""
|
| 21 |
+
import math, os, sys
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch, torch.nn as nn, torch.nn.functional as F
|
| 24 |
+
|
| 25 |
+
ARC_PKG = os.path.dirname(os.path.abspath(__file__)) # [release] vendored arc_audio subset lives here
|
| 26 |
+
if ARC_PKG not in sys.path:
|
| 27 |
+
sys.path.insert(0, ARC_PKG)
|
| 28 |
+
from arc_audio.model import Trunk, TagHead, EntityHeads, zero_carry, encode_names, NEG # noqa: E402
|
| 29 |
+
from arc_audio.ontology import QUERIES as ARC_Q, POOLED # noqa: E402
|
| 30 |
+
from arc_audio.wordch import WordChannel, P_MAX, SRC_ROW # noqa: E402
|
| 31 |
+
|
| 32 |
+
HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
|
| 33 |
+
from tagtable import FINE_DOMAINS, ASR_SOURCES # noqa: E402
|
| 34 |
+
|
| 35 |
+
TOK_PATH = os.path.join(ARC_PKG, "moonshine-streaming-tiny", "tokenizer.json")
|
| 36 |
+
SCRATCH_10M = dict(d=256, d_w=256, d_e=256, vocab=8192, n_layers=6, adapters=16, d_state=64, head_dim=64, dropout=0.1) # 9.79 M
|
| 37 |
+
WORD_Q = ("frame", "span_beg", "span_in", "span_end", "ev_at", "ev_imm", "ev_rec")
|
| 38 |
+
FEATS = {"gap": 10, "dur": 8, "fin": 3, "conf": 7, "chg": 3, "asr": 8}
|
| 39 |
+
D_MAX = 8
|
| 40 |
+
TIER_W = {"T0": 1.0, "T1": 0.5, "T2": 0.5}
|
| 41 |
+
ARC_CFG = dict(d_in=640, d_model=512, n_layers=5, d_state=64, head_dim=64, dropout=0.2, in_drop=0.2, chunk=128, n_dom=16,
|
| 42 |
+
delays=[0, 1, 2, 3, 5, 8], adapters=64, long_mem=True) # release config.json "model.trunk"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
BPE8K = os.path.join(os.environ.get("ARCTAG_HOME", os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "tok", "bpe8k", "tokenizer.json") # [release] # own byte-level BPE (train_bpe.py), words as " w"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ArcPieces:
|
| 49 |
+
"""tokenizer adapter for data.Windows / the streaming engines: word -> piece ids, no specials.
|
| 50 |
+
moonshine (the Arc package's tokenizer): the word itself (its normalizer prepends the space marker);
|
| 51 |
+
own byte-level BPE (tok/bpe8k): " " + word, exactly as train_bpe.py fed it."""
|
| 52 |
+
def __init__(self, path=TOK_PATH):
|
| 53 |
+
from tokenizers import Tokenizer
|
| 54 |
+
self.path = path
|
| 55 |
+
self.t = Tokenizer.from_file(path)
|
| 56 |
+
self.vocab_size = self.t.get_vocab_size()
|
| 57 |
+
self.space = "bpe8k" in path or "/tok/" in path
|
| 58 |
+
self.cls_token_id = 0; self.pad_token_id = 0; self.unk_token_id = 0; self.mask_token_id = 0
|
| 59 |
+
|
| 60 |
+
def word(self, w):
|
| 61 |
+
return (" " + w.strip()) if self.space else w.strip()
|
| 62 |
+
|
| 63 |
+
def __call__(self, texts, add_special_tokens=False):
|
| 64 |
+
enc = self.t.encode_batch([self.word(t) for t in texts], add_special_tokens=False)
|
| 65 |
+
return {"input_ids": [e.ids for e in enc]}
|
| 66 |
+
|
| 67 |
+
def encode_word(self, w):
|
| 68 |
+
return self.t.encode(self.word(w), add_special_tokens=False).ids
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TableShim:
|
| 72 |
+
def __init__(self, T):
|
| 73 |
+
self.n = T.n; self.valid = T.valid
|
| 74 |
+
self.cols = {q: list(T.cols[q]) for q in ARC_Q}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def frames(t):
|
| 78 |
+
return int(round(t * 10)) if t is not None else None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ArcText(nn.Module):
|
| 82 |
+
def __init__(self, table, n_in_war=41, n_units=83, d_in_new=256, train_tokE=True, scratch=None):
|
| 83 |
+
"""scratch=None: the Arc's own dims (34.1 M, warm start from PK8a). scratch=dict(d, d_w, d_e, vocab, n_layers, adapters,
|
| 84 |
+
d_state, head_dim): the same block types, narrower, trained from scratch (audio-only / unused sub-modules removed)."""
|
| 85 |
+
super().__init__()
|
| 86 |
+
self.T = table; self.scratch = scratch
|
| 87 |
+
tc = dict(ARC_CFG); tc["d_in"] = d_in_new
|
| 88 |
+
if scratch:
|
| 89 |
+
tc.update(d_in=scratch["d_w"], d_model=scratch["d"], n_layers=scratch["n_layers"], adapters=scratch["adapters"],
|
| 90 |
+
d_state=scratch.get("d_state", 64), head_dim=scratch.get("head_dim", 64), delays=[0], dropout=scratch.get("dropout", 0.1))
|
| 91 |
+
self.trunk = Trunk(**tc)
|
| 92 |
+
d = tc["d_model"]; self.d = d
|
| 93 |
+
nn.init.ones_(self.trunk.in_norm.weight); nn.init.zeros_(self.trunk.in_norm.bias)
|
| 94 |
+
self.head = TagHead(TableShim(table), n_text=table.E.shape[1], d_model=d, d_k=d)
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
self.head.E.copy_(torch.as_tensor(table.E))
|
| 97 |
+
if scratch:
|
| 98 |
+
self.ent = EntityHeads(d_model=d, d_key=d, n_units=n_units, span=False, span_new=False, sim=False)
|
| 99 |
+
for k in ("nwt", "unit", "clock", "approx"): # numeral value heads: no stage-1 target -> removed
|
| 100 |
+
delattr(self.ent, k)
|
| 101 |
+
self.word = WordChannel(d_model=d, d_k=d, n_in=n_in_war, k_slots=4, n_nwt=43, d_w=scratch["d_w"], n_tok=scratch["vocab"],
|
| 102 |
+
d_e=scratch["d_e"], d_c=64)
|
| 103 |
+
for k in ("fq", "fk", "fv", "fo", "R0", "R1", "QI", "bI", "bB", "nwt", "Wa", "hav"): # audio-fusion / unused read-outs
|
| 104 |
+
delattr(self.word, k)
|
| 105 |
+
del self.word._buffers["tok_E"]
|
| 106 |
+
self.word.tok_E = nn.Parameter(torch.randn(scratch["vocab"], scratch["d_e"]) * 0.02)
|
| 107 |
+
else:
|
| 108 |
+
self.ent = EntityHeads(d_model=d, d_key=d, n_units=n_units, span=True, span_new=True, sim=True)
|
| 109 |
+
self.word = WordChannel(d_model=d, d_k=d, n_in=n_in_war, k_slots=4, n_nwt=43)
|
| 110 |
+
if train_tokE: # end-to-end: the piece table becomes a trainable fp32 parameter
|
| 111 |
+
del self.word._buffers["tok_E"]
|
| 112 |
+
self.word.tok_E = nn.Parameter(torch.zeros(32768, 320))
|
| 113 |
+
self.feat = nn.ModuleDict({k: nn.Embedding(n, self.word.ln.normalized_shape[0]) for k, n in FEATS.items()})
|
| 114 |
+
for m in self.feat.values():
|
| 115 |
+
nn.init.zeros_(m.weight)
|
| 116 |
+
self.delay_q = nn.Embedding(D_MAX + 1, d); nn.init.zeros_(self.delay_q.weight)
|
| 117 |
+
self.bB_all = nn.Parameter(torch.zeros(table.n)) # span_beg bias per r8 tag (war bB on the r5 columns)
|
| 118 |
+
self.register_buffer("cols_span_beg", torch.tensor(table.cols["span_beg"], dtype=torch.long), persistent=False)
|
| 119 |
+
self.ver = nn.Linear(2 * d, 1)
|
| 120 |
+
self.lm = nn.Linear(d, self.word.tok_E.shape[1])
|
| 121 |
+
|
| 122 |
+
# ------------------------------------------------------------------ word input
|
| 123 |
+
def word_inputs(self, b):
|
| 124 |
+
"""token-level batch (data.collate, ArcPieces ids) -> pieces (B,W,P_MAX) (first 7 + last), counts, word features"""
|
| 125 |
+
ids, tok_word, feats = b["ids"], b["tok_word"], b["feats"]; W = b["wvalid"].shape[1]
|
| 126 |
+
B, S = ids.shape; dev = ids.device
|
| 127 |
+
valid = tok_word >= 0; tw = tok_word.clamp(min=0)
|
| 128 |
+
pos = torch.arange(S, device=dev)[None].expand(B, S)
|
| 129 |
+
first = torch.full((B, W), S, dtype=torch.long, device=dev).scatter_reduce(1, tw, torch.where(valid, pos, torch.full_like(pos, S)), "amin")
|
| 130 |
+
rank = pos - torch.gather(first, 1, tw)
|
| 131 |
+
npc_all = torch.zeros(B, W, dtype=torch.long, device=dev).scatter_add(1, tw, valid.long())
|
| 132 |
+
nw = torch.gather(npc_all, 1, tw)
|
| 133 |
+
keep = valid & ((rank < P_MAX - 1) | (rank == nw - 1))
|
| 134 |
+
slot = torch.where(rank < P_MAX - 1, rank, torch.full_like(rank, P_MAX - 1))
|
| 135 |
+
pcs = torch.zeros(B, W, P_MAX, dtype=torch.long, device=dev)
|
| 136 |
+
bi = torch.arange(B, device=dev)[:, None].expand(B, S)
|
| 137 |
+
pcs[bi[keep], tw[keep], slot[keep]] = ids[keep]
|
| 138 |
+
npc = npc_all.clamp(max=P_MAX)
|
| 139 |
+
sc = lambda src: torch.zeros(B, W, dtype=torch.long, device=dev).scatter(1, torch.where(valid, tw, torch.zeros_like(tw)),
|
| 140 |
+
torch.where(valid, src, torch.zeros_like(src)))
|
| 141 |
+
fw = {k: sc(feats[k]) for k in self.feat if k in feats}
|
| 142 |
+
spk = sc(feats["spk"]) if "spk" in feats else torch.zeros(B, W, dtype=torch.long, device=dev)
|
| 143 |
+
return pcs, npc, fw, spk
|
| 144 |
+
|
| 145 |
+
def embed(self, pcs, npc, fw, wfl, wsrc):
|
| 146 |
+
"""arc.word.embed_in with no audio + provider features. wfl (B,W,5) Arc flags (npc filled here), wsrc (B,W) source row"""
|
| 147 |
+
fl = wfl.clone(); fl[..., 2] = npc.clamp(0, P_MAX)
|
| 148 |
+
if self.scratch: # = WordChannel.embed_in without the audio terms
|
| 149 |
+
w = self.word; Et = w.tok_E[pcs].float()
|
| 150 |
+
pm = (torch.arange(P_MAX, device=pcs.device)[None, None] < npc[..., None]).float()[..., None]
|
| 151 |
+
mean = ((Et + w.ppos) * pm).sum(2) / npc.clamp(min=1)[..., None].float()
|
| 152 |
+
last = torch.gather(Et, 2, (npc - 1).clamp(min=0)[..., None, None].expand(-1, -1, 1, Et.shape[-1]))[:, :, 0]
|
| 153 |
+
e = w.ln(w.W1(mean) + w.W2(last))
|
| 154 |
+
for i, m in enumerate(w.fl):
|
| 155 |
+
e = e + m(fl[..., i].clamp(0, m.num_embeddings - 1))
|
| 156 |
+
e = e + w.src(wsrc.clamp(0, w.src.num_embeddings - 1))
|
| 157 |
+
else:
|
| 158 |
+
z = torch.zeros(*pcs.shape[:2], self.d, device=pcs.device)
|
| 159 |
+
e = self.word.embed_in(pcs, npc, fl, wsrc, z, z, torch.zeros(pcs.shape[:2], dtype=torch.bool, device=pcs.device))
|
| 160 |
+
for k, m in self.feat.items():
|
| 161 |
+
if k in fw:
|
| 162 |
+
e = e + m(fw[k].clamp(0, m.num_embeddings - 1))
|
| 163 |
+
return e
|
| 164 |
+
|
| 165 |
+
def keys(self):
|
| 166 |
+
return self.head.keys()
|
| 167 |
+
|
| 168 |
+
def span_beg_logits(self, u, valid, k_i, dom, K):
|
| 169 |
+
"""war B read-out (arc.word.war_*) on the word-channel output with right context k (per word, 0..2) = WordChannel.war_logits
|
| 170 |
+
with the text table's span_beg columns and a per-r8-tag bias"""
|
| 171 |
+
c = self.cols_span_beg; Kb = K[c]; w = self.word; W = u.shape[1]
|
| 172 |
+
out = None
|
| 173 |
+
for k in range(3):
|
| 174 |
+
us = F.pad(u[:, k:], (0, 0, 0, k)) if k else torch.zeros_like(u)
|
| 175 |
+
v = w.war_ln(u + w.war_r(us) + w.war_k.weight[k])
|
| 176 |
+
zB = w.QB(v).float() @ Kb.T + self.bB_all[c]
|
| 177 |
+
out = zB if out is None else torch.where((k_i == k)[..., None], zB, out)
|
| 178 |
+
return out.masked_fill(~self.head.valid[dom][:, c][:, None, :], NEG)
|
| 179 |
+
|
| 180 |
+
@staticmethod
|
| 181 |
+
def utt_last(uend, wvalid):
|
| 182 |
+
B, W = uend.shape; dev = uend.device
|
| 183 |
+
t = torch.arange(W, device=dev)[None].expand(B, W)
|
| 184 |
+
last_valid = (wvalid.long().sum(1) - 1).clamp(min=0)[:, None]
|
| 185 |
+
e = torch.where(uend | (t == last_valid), t, torch.full_like(t, W - 1))
|
| 186 |
+
return torch.flip(torch.cummin(torch.flip(e, [1]), 1).values, [1])
|
| 187 |
+
|
| 188 |
+
def tier_delays(self, tier, uend, wvalid):
|
| 189 |
+
B, W = uend.shape
|
| 190 |
+
t = torch.arange(W, device=uend.device)[None].expand(B, W)
|
| 191 |
+
to_end = self.utt_last(uend, wvalid) - t
|
| 192 |
+
if tier == "T0":
|
| 193 |
+
return torch.zeros_like(t)
|
| 194 |
+
return to_end.clamp(min=0, max=3 if tier == "T1" else D_MAX)
|
| 195 |
+
|
| 196 |
+
def delayed(self, h, d_i):
|
| 197 |
+
"""read-out state of word i at delay d: h_{i+d} + delay_q(d) [+ stage 2 dmix(h_i) for d > 0: the delayed read-out sees word i
|
| 198 |
+
itself; stage 1 drifted toward word i+d (T1 person spans peaked +3 words off)]"""
|
| 199 |
+
W = h.shape[1]
|
| 200 |
+
j = (torch.arange(W, device=h.device)[None] + d_i).clamp(max=W - 1)
|
| 201 |
+
out = torch.gather(h, 1, j[..., None].expand(-1, -1, h.shape[-1])) + self.delay_q(d_i.clamp(0, D_MAX))
|
| 202 |
+
if hasattr(self, "dattn"): # v2 A2: attention over h_i..h_{i+d} (zero-init output)
|
| 203 |
+
out = out + self.dattn(h, d_i)
|
| 204 |
+
elif hasattr(self, "dmix"):
|
| 205 |
+
out = out + self.dmix(h) * (d_i > 0)[..., None].to(h.dtype)
|
| 206 |
+
return out
|
| 207 |
+
|
| 208 |
+
def add_delay_mix(self):
|
| 209 |
+
"""stage 2: W_d (d x d, no bias, zero-init -> stage-1 outputs unchanged; T0 never affected) adding word i's own state to its
|
| 210 |
+
delayed read-out"""
|
| 211 |
+
self.dmix = nn.Linear(self.d, self.d, bias=False); nn.init.zeros_(self.dmix.weight)
|
| 212 |
+
return self
|
| 213 |
+
|
| 214 |
+
def add_v2(self, gattn_at=(1, 3), delay_attn=True, span_head=True, utt_att=True, gattn_bias=False):
|
| 215 |
+
"""AT10M-v2 (v2.py): global attention layers after trunk blocks gattn_at, attention delayed read-out, per-family span head,
|
| 216 |
+
attention-pooled utterance read-out. Every new output path is zero-initialised except the span head (new read-out)."""
|
| 217 |
+
import v2 as V2
|
| 218 |
+
if gattn_at:
|
| 219 |
+
self.gat = nn.ModuleDict({str(i): V2.GlobalAttn(self.d, biased=gattn_bias) for i in gattn_at}) # v2.1: ALiBi + key prior + gate
|
| 220 |
+
if delay_attn:
|
| 221 |
+
self.dattn = V2.DelayAttn(self.d, D_MAX)
|
| 222 |
+
if hasattr(self, "dmix"):
|
| 223 |
+
del self.dmix
|
| 224 |
+
if span_head:
|
| 225 |
+
self.span_head = V2.SpanHead(self.d, self.T)
|
| 226 |
+
if utt_att:
|
| 227 |
+
self.utt_att = nn.Linear(self.d, 1); nn.init.zeros_(self.utt_att.weight); nn.init.zeros_(self.utt_att.bias)
|
| 228 |
+
return self
|
| 229 |
+
|
| 230 |
+
def add_memread(self, d_ext=0):
|
| 231 |
+
"""v2.2 (a): session mention memory read into the span read-outs (v2.MemRead; exact no-op at insertion)"""
|
| 232 |
+
import v2 as V2
|
| 233 |
+
self.memr = V2.MemRead(self.d, d_ext=d_ext, d_u=len(self.cols_span_beg))
|
| 234 |
+
return self
|
| 235 |
+
|
| 236 |
+
def utt_pool_att(self, h, ustart):
|
| 237 |
+
"""v2 A6: utterance pool = softmax(utt_att(z))-weighted running mean of z = h + pool_in.utt(h) within the utterance (zero init
|
| 238 |
+
-> the plain running mean of pool_segment)"""
|
| 239 |
+
z = (h + self.head.pool_in["utt"](h)).float()
|
| 240 |
+
w = torch.exp(self.utt_att(z).clamp(-10, 10)) # (B, W, 1)
|
| 241 |
+
cw = torch.cumsum(w, 1); cz = torch.cumsum(w * z, 1)
|
| 242 |
+
idx = torch.arange(h.shape[1], device=h.device)[None].expand_as(ustart)
|
| 243 |
+
st = torch.cummax(torch.where(ustart, idx, torch.zeros_like(idx)), 1).values # start index of the current utterance
|
| 244 |
+
prev = (st - 1).clamp(min=0)
|
| 245 |
+
has = (st > 0)[..., None]
|
| 246 |
+
cw0 = torch.where(has, torch.gather(cw, 1, prev[..., None]), torch.zeros_like(cw))
|
| 247 |
+
cz0 = torch.where(has, torch.gather(cz, 1, prev[..., None].expand(-1, -1, z.shape[-1])), torch.zeros_like(cz))
|
| 248 |
+
return (cz - cz0) / (cw - cw0).clamp(min=1e-9)
|
| 249 |
+
|
| 250 |
+
def encode(self, b):
|
| 251 |
+
pcs, npc, fw, spk = self.word_inputs(b)
|
| 252 |
+
e = self.embed(pcs, npc, fw, b["wfl"], b["wsrc"])
|
| 253 |
+
u = self.word.run_enc(e, b["wvalid"])
|
| 254 |
+
dom = b["dom"]
|
| 255 |
+
if hasattr(self, "gat"):
|
| 256 |
+
import v2 as V2
|
| 257 |
+
h = V2.trunk_forward(self.trunk, self.gat, self.trunk_in(u), dom, torch.zeros_like(dom),
|
| 258 |
+
ckpt=getattr(self, "grad_ckpt", False) and self.training)
|
| 259 |
+
return pcs, spk, u, h
|
| 260 |
+
if getattr(self, "grad_ckpt", False) and self.training:
|
| 261 |
+
h = self.trunk_ckpt(self.trunk_in(u), dom, torch.zeros_like(dom))
|
| 262 |
+
else:
|
| 263 |
+
h, _ = self.trunk(self.trunk_in(u), dom, torch.zeros_like(dom))
|
| 264 |
+
return pcs, spk, u, h
|
| 265 |
+
|
| 266 |
+
def trunk_ckpt(self, x, dom, delay):
|
| 267 |
+
"""= Trunk.forward (state None) with each [FiLM -> Mamba-3 -> MLP] block activation-checkpointed (training memory)"""
|
| 268 |
+
from torch.utils.checkpoint import checkpoint
|
| 269 |
+
from arc_audio.mamba3.ssd import mamba3_layer_ssd_state, _f
|
| 270 |
+
tr = self.trunk; h, e = tr._cond(x, dom, delay)
|
| 271 |
+
for i, (lay, mlp) in enumerate(zip(tr.layers, tr.mlps)):
|
| 272 |
+
def blk(h, e, i=i, lay=lay, mlp=mlp):
|
| 273 |
+
gb = tr.film[i](e)[:, None]; h = h * (1 + gb[..., :h.shape[-1]]) + gb[..., h.shape[-1]:]
|
| 274 |
+
s, _ = mamba3_layer_ssd_state(lay, _f(h), None, tr.chunk)
|
| 275 |
+
h = h + s
|
| 276 |
+
return h + mlp(h)
|
| 277 |
+
h = checkpoint(blk, h, e, use_reentrant=False)
|
| 278 |
+
return tr.out_norm(tr._adapt(h, dom))
|
| 279 |
+
|
| 280 |
+
def trunk_in(self, u):
|
| 281 |
+
return u # Trunk applies in_norm + inp itself (d_in = 256)
|
| 282 |
+
|
| 283 |
+
def forward(self, b, tiers=("T0",), lm=False):
|
| 284 |
+
pcs, spk, u, h = self.encode(b)
|
| 285 |
+
dom = b["dom"]; W = h.shape[1]
|
| 286 |
+
spk_i = torch.where(spk > 0, spk - 1, torch.full_like(spk, -1))
|
| 287 |
+
st = b["ustart"].clone(); st[:, 0] = True
|
| 288 |
+
zin, _ = self.head.pooled(h, spk_i, st, zero_carry(h.shape[0], h.shape[-1], h.device))
|
| 289 |
+
if hasattr(self, "utt_att"):
|
| 290 |
+
zin["utt"] = self.utt_pool_att(h, st)
|
| 291 |
+
K = self.keys()
|
| 292 |
+
pooled = {q: self.head.logits_q(q, zin[q], dom, K) for q in POOLED}
|
| 293 |
+
out = {"h": h, "u": u, "tiers": {}}; mkv = None
|
| 294 |
+
for tier in tiers:
|
| 295 |
+
dl = self.tier_delays(tier, b["uend"], b["wvalid"])
|
| 296 |
+
hd = self.delayed(h, dl)
|
| 297 |
+
if hasattr(self, "memr"): # v2.2 (a): earlier-mention read (keys shared across tiers)
|
| 298 |
+
if mkv is None:
|
| 299 |
+
mkv = self.memr.keys(h, b.get("cng"))
|
| 300 |
+
if self.training and torch.is_grad_enabled(): # activation-checkpointed: GPU headroom is small
|
| 301 |
+
from torch.utils.checkpoint import checkpoint
|
| 302 |
+
y_, a_ = checkpoint(lambda hd_, h_, k_, v_, m_: self.memr(hd_, h_, kv=(k_, v_, m_)), hd, h, *mkv, use_reentrant=False)
|
| 303 |
+
else:
|
| 304 |
+
y_, a_ = self.memr(hd, h, kv=mkv)
|
| 305 |
+
hd = hd + y_.to(hd.dtype)
|
| 306 |
+
lg = {q: self.head.logits_q(q, hd, dom, K) for q in WORD_Q if q != "span_beg"}
|
| 307 |
+
lg["span_beg"] = self.span_beg_logits(u, b["wvalid"], dl.clamp(max=2), dom, K)
|
| 308 |
+
if hasattr(self, "memr"): # v2.2 (a): gated span_beg logit increment (read at word i)
|
| 309 |
+
lg["span_beg"] = lg["span_beg"] + self.memr.to_u(a_).float()
|
| 310 |
+
lg.update(pooled)
|
| 311 |
+
out["tiers"][tier] = lg
|
| 312 |
+
if hasattr(self, "kb_spot"):
|
| 313 |
+
out.setdefault("kb_spot", {})[tier] = self.kb_spot(hd).float()
|
| 314 |
+
if hasattr(self, "span_head"):
|
| 315 |
+
out.setdefault("span_head", {})[tier] = self.span_head(hd)
|
| 316 |
+
if tier == "T0" and hasattr(self, "pii_imm"): # stage 2: "a PII span starts within 1..3 words" (T0 only)
|
| 317 |
+
out["pii_imm"] = self.pii_imm(hd).float()
|
| 318 |
+
out["logits"] = out["tiers"][tiers[0]]
|
| 319 |
+
if lm:
|
| 320 |
+
out["lm_h"] = self.lm(h) # logits at the selected rows only (train.py)
|
| 321 |
+
out["lm"] = None if getattr(self, "lm_rows", False) else out["lm_h"] @ self.word.tok_E.float().t()
|
| 322 |
+
out["lm_target"] = torch.cat([pcs[:, 1:, 0], torch.zeros_like(pcs[:, :1, 0])], 1)
|
| 323 |
+
out["lm_valid"] = torch.cat([b["wvalid"][:, 1:], torch.zeros_like(b["wvalid"][:, :1])], 1)
|
| 324 |
+
return out
|
| 325 |
+
|
| 326 |
+
# ------------------------------------------------------------------ entities: verification + KB pointer
|
| 327 |
+
def span_rep(self, hw, spans):
|
| 328 |
+
cs = F.pad(torch.cumsum(hw, 0), (0, 0, 1, 0))
|
| 329 |
+
mean = (cs[spans[:, 1]] - cs[spans[:, 0]]) / (spans[:, 1] - spans[:, 0]).clamp(min=1)[:, None].to(hw.dtype)
|
| 330 |
+
return torch.cat([mean, hw[spans[:, 1] - 1]], -1)
|
| 331 |
+
|
| 332 |
+
# ------------------------------------------------------------------ long-list KB (stage 2, DESIGN.md section 13; +~136 k params)
|
| 333 |
+
def add_kb_long(self):
|
| 334 |
+
import kb as KBM
|
| 335 |
+
D = self.d; dn = self.ent.names.d
|
| 336 |
+
self.kb_len = nn.Embedding(7, D) # query span length 1..6 (+ longer) -> added to the span-end state
|
| 337 |
+
self.kb_type = nn.Embedding(len(KBM.TYPES), dn) # entry type -> added to the NameEncoder entry vector
|
| 338 |
+
self.kb_spot = nn.Linear(D, 6) # "a KB entry of length L ends at this word" (L = 1..6), per tier
|
| 339 |
+
self.ver_e = nn.Sequential(nn.Linear(2 * D + 4 * dn, 128), nn.GELU(), nn.Linear(128, 1)) # entry-aware verification
|
| 340 |
+
for m in (self.kb_len, self.kb_type):
|
| 341 |
+
nn.init.zeros_(m.weight)
|
| 342 |
+
nn.init.zeros_(self.kb_spot.weight); nn.init.constant_(self.kb_spot.bias, -4.0)
|
| 343 |
+
return self
|
| 344 |
+
|
| 345 |
+
def add_kb_str(self, cng=True, vstr=True):
|
| 346 |
+
"""v2.2 (b) char-n-gram mention key (hashed char 3-grams of the heard span -> added to the KB query before normalisation) and
|
| 347 |
+
(c) ver_e string features (span text vs entry text similarity -> added to ver_e's hidden pre-activation). Both zero-init:
|
| 348 |
+
outputs are bit-identical until trained; used only where the caller passes the span texts / features (else skipped)."""
|
| 349 |
+
import kb_strfeat as KS
|
| 350 |
+
dn = self.ent.names.d
|
| 351 |
+
if cng:
|
| 352 |
+
self.kb_cng = nn.EmbeddingBag(KS.NB, dn, mode="mean"); nn.init.zeros_(self.kb_cng.weight)
|
| 353 |
+
if vstr:
|
| 354 |
+
self.ver_s = nn.Linear(KS.NF, self.ver_e[0].out_features, bias=False); nn.init.zeros_(self.ver_s.weight)
|
| 355 |
+
return self
|
| 356 |
+
|
| 357 |
+
def add_redact(self, n=8):
|
| 358 |
+
"""stage 2 real-time redaction (DESIGN.md section 14): the PII-imminent read-out, one logit per redact.PII type
|
| 359 |
+
(D x 8 + 8 = 2,056 params); zero weight, bias -4 (rare event) so the stage-1 outputs are unchanged at the start"""
|
| 360 |
+
self.pii_imm = nn.Linear(self.d, n)
|
| 361 |
+
nn.init.zeros_(self.pii_imm.weight); nn.init.constant_(self.pii_imm.bias, -4.0)
|
| 362 |
+
return self
|
| 363 |
+
|
| 364 |
+
def add_spk_link(self, flux=True):
|
| 365 |
+
"""stage 2 text speaker linking (DESIGN.md 14.8, speaker_link.SpkLink) + input features "spksrc" (speaker ids: none / gold /
|
| 366 |
+
ASR-like) and, with flux, "turn" (Flux-like turn events). New embeddings are zero-initialised (stage-1 outputs unchanged)."""
|
| 367 |
+
import speaker_link as SL
|
| 368 |
+
n_roles = len(role_cols(self.T))
|
| 369 |
+
self.spk_link = SL.SpkLink(self.d, n_roles)
|
| 370 |
+
dw = self.word.ln.normalized_shape[0]
|
| 371 |
+
self.feat["spksrc"] = nn.Embedding(3, dw); nn.init.zeros_(self.feat["spksrc"].weight)
|
| 372 |
+
if flux:
|
| 373 |
+
self.feat["turn"] = nn.Embedding(4, dw); nn.init.zeros_(self.feat["turn"].weight)
|
| 374 |
+
return self
|
| 375 |
+
|
| 376 |
+
def kb_entry_vecs(self, texts, types):
|
| 377 |
+
"""entry texts + type ids -> (N, dn) unit vectors (the model's NameEncoder + a type embedding); precomputable per list"""
|
| 378 |
+
dev = self.bB_all.device
|
| 379 |
+
ids = torch.as_tensor(encode_names(texts), device=dev)
|
| 380 |
+
e = self.ent.names(ids, torch.zeros(len(texts), dtype=torch.long, device=dev))
|
| 381 |
+
return F.normalize(e + self.kb_type(torch.as_tensor(types, device=dev)), dim=-1)
|
| 382 |
+
|
| 383 |
+
def kb_query(self, h, bi, a, b, qtext=None):
|
| 384 |
+
"""span queries: h (B, W, D); bi / a / b (n,) -> rep (n, 2D) [mean, end], q (n, dn) unit; qtext: heard span texts (v2.2 b)"""
|
| 385 |
+
cs = F.pad(torch.cumsum(h, 1), (0, 0, 1, 0))
|
| 386 |
+
mean = (cs[bi, b] - cs[bi, a]) / (b - a).clamp(min=1)[:, None].to(h.dtype)
|
| 387 |
+
hend = h[bi, b - 1]; rep = torch.cat([mean, hend], -1)
|
| 388 |
+
q0 = self.ent.query(hend.float() + self.kb_len((b - a).clamp(1, 6)))
|
| 389 |
+
if qtext is not None and hasattr(self, "kb_cng"):
|
| 390 |
+
import kb_strfeat as KS
|
| 391 |
+
ids, off = KS.cng_batch(qtext)
|
| 392 |
+
q0 = q0 + self.kb_cng(torch.as_tensor(ids, device=h.device), torch.as_tensor(off, device=h.device)).to(q0.dtype)
|
| 393 |
+
q = F.normalize(q0, dim=-1)
|
| 394 |
+
return rep, q
|
| 395 |
+
|
| 396 |
+
def kb_scores(self, q, E):
|
| 397 |
+
"""q (n, dn), E (n, K, dn) or (M, dn) -> candidate logits; null logit (n,) = the pointer's 'new' slot"""
|
| 398 |
+
sc = self.ent.log_scale.clamp(max=math.log(50.0)).exp()
|
| 399 |
+
s = torch.einsum("nd,nkd->nk", q, E) if E.dim() == 3 else q @ E.t()
|
| 400 |
+
null = (q @ F.normalize(self.ent.special, dim=-1)[0]) * sc + self.ent.special_b[0]
|
| 401 |
+
return s * sc, null
|
| 402 |
+
|
| 403 |
+
def kb_ver_e(self, rep, q, e, sf=None):
|
| 404 |
+
"""entry-aware verification logit: the span heard as exactly this entry (rejects sound-alikes / wrong digits / qualifiers);
|
| 405 |
+
sf (..., NF): v2.2 (c) string features of (span text, entry text)"""
|
| 406 |
+
x = torch.cat([rep.float(), q, e, q * e, (q - e).abs()], -1)
|
| 407 |
+
if sf is None or not hasattr(self, "ver_s"):
|
| 408 |
+
return self.ver_e(x)[..., 0]
|
| 409 |
+
z = self.ver_e[0](x) + self.ver_s(sf.to(x.dtype))
|
| 410 |
+
return self.ver_e[2](self.ver_e[1](z))[..., 0]
|
| 411 |
+
|
| 412 |
+
def kb_embed(self, names, kinds=None):
|
| 413 |
+
ids = torch.as_tensor(encode_names(names), device=self.bB_all.device)
|
| 414 |
+
kk = torch.zeros(len(names), dtype=torch.long, device=ids.device) if kinds is None else kinds
|
| 415 |
+
return self.ent.names(ids, kk)
|
| 416 |
+
|
| 417 |
+
def kb_logits(self, h_end, kbe):
|
| 418 |
+
"""h at the span's last word (n,D), KB embeddings (R,d) -> (n, 2+R) [new, generic, candidates] (arc ptr head)"""
|
| 419 |
+
n = h_end.shape[0]
|
| 420 |
+
cm = torch.ones(1, n, kbe.shape[0], dtype=torch.bool, device=h_end.device)
|
| 421 |
+
return self.ent.ptr_logits(h_end[None], torch.zeros(1, n, self.d, device=h_end.device), kbe[None], cm)[0]
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# ---------------------------------------------------------------------- warm start
|
| 425 |
+
@torch.no_grad()
|
| 426 |
+
def role_cols(T):
|
| 427 |
+
"""positions (within the speaker read-out's columns) of the speaker.role.* tags"""
|
| 428 |
+
return [k for k, i in enumerate(T.cols["speaker"]) if T.ids[i].startswith("speaker.role.")]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def warm_start(model, export_dir, verbose=True):
|
| 432 |
+
"""PK8a export (arc_pk.safetensors, keys 'arc.*') -> ArcText. Same dims everywhere except the replaced input projection."""
|
| 433 |
+
from safetensors.torch import load_file
|
| 434 |
+
sd = load_file(os.path.join(export_dir, "arc_pk.safetensors"))
|
| 435 |
+
sd = {k[4:]: v for k, v in sd.items() if k.startswith("arc.")}
|
| 436 |
+
tgt = dict(model.named_parameters()); tgt.update({k: v for k, v in model.named_buffers() if k in ("head.E", "word.tok_E")})
|
| 437 |
+
rep = {"exact": [], "derived": [], "new": [], "dropped": [], "shape_mismatch": []}
|
| 438 |
+
for k, v in sd.items():
|
| 439 |
+
if k in ("trunk.inp.weight", "trunk.inp.bias", "trunk.in_norm.weight", "trunk.in_norm.bias") or k.startswith(("diar.", "bio.")):
|
| 440 |
+
rep["dropped"].append(k); continue
|
| 441 |
+
if k not in tgt:
|
| 442 |
+
rep["dropped"].append(k); continue
|
| 443 |
+
if tgt[k].shape != v.shape:
|
| 444 |
+
rep["shape_mismatch"].append((k, tuple(v.shape), tuple(tgt[k].shape))); continue
|
| 445 |
+
tgt[k].copy_(v.to(tgt[k].dtype)); rep["exact"].append(k)
|
| 446 |
+
# input projection = the word -> frame value path of the word channel (fo . fv)
|
| 447 |
+
fo_w, fo_b, fv_w, fv_b = (sd[x].float() for x in ("word.fo.weight", "word.fo.bias", "word.fv.weight", "word.fv.bias"))
|
| 448 |
+
tgt["trunk.inp.weight"].copy_(fo_w @ fv_w); tgt["trunk.inp.bias"].copy_(fo_w @ fv_b + fo_b)
|
| 449 |
+
rep["derived"] += ["trunk.inp.weight", "trunk.inp.bias"]
|
| 450 |
+
# span_beg bias per r8 tag from war bB (r5 span_in column order = the grown table's first n5 span_in columns)
|
| 451 |
+
import json
|
| 452 |
+
g = json.load(open(os.path.join(export_dir, "arc_grow.json")))
|
| 453 |
+
c5 = g["cols"]["span_in"][: sd["word.bB"].shape[0]]
|
| 454 |
+
for j, i in enumerate(c5):
|
| 455 |
+
model.bB_all[i] = sd["word.bB"][j]
|
| 456 |
+
rep["derived"].append("bB_all")
|
| 457 |
+
done = set(rep["exact"]) | set(rep["derived"])
|
| 458 |
+
rep["new"] = [n for n, _ in model.named_parameters() if n not in done]
|
| 459 |
+
P = dict(model.named_parameters())
|
| 460 |
+
cnt = lambda names: sum((P[n] if n in P else tgt[n]).numel() for n in names if n in P or n in tgt)
|
| 461 |
+
rep["summary"] = {k: dict(tensors=len(rep[k]), params=cnt(rep[k])) for k in ("exact", "derived", "new")}
|
| 462 |
+
rep["summary"]["dropped"] = dict(tensors=len(rep["dropped"]), params=sum(sd[k].numel() for k in rep["dropped"]))
|
| 463 |
+
rep["summary"]["export_total"] = sum(v.numel() for v in sd.values())
|
| 464 |
+
if verbose:
|
| 465 |
+
print("warm start", rep["summary"], "mismatch", rep["shape_mismatch"])
|
| 466 |
+
return rep
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def param_table(m):
|
| 470 |
+
out = {}
|
| 471 |
+
extra = [] if any(n == "word.tok_E" for n, _ in m.named_parameters()) else [("word.tok_E", m.word.tok_E)]
|
| 472 |
+
for n, p in list(m.named_parameters()) + extra:
|
| 473 |
+
g = n.split(".")[0]
|
| 474 |
+
if g == "trunk":
|
| 475 |
+
g = "trunk." + ("layers (Mamba-3)" if ".layers." in n else "mlps" if ".mlps." in n else "film" if ".film." in n else
|
| 476 |
+
"adapt" if "adapt" in n else "inp/in_norm (new text input proj)" if (".inp." in n or "in_norm" in n) else "emb/norm")
|
| 477 |
+
elif g == "word":
|
| 478 |
+
g = "word." + ("tok_E (piece table)" if n == "word.tok_E" else "enc (2 local attn layers)" if ".enc." in n else
|
| 479 |
+
"war/nwt (word read-outs)" if any(x in n for x in ("war", "QB", "QI", "bB", "bI", "nwt")) else
|
| 480 |
+
"fusion (fq/fk/fv/fo/R0/R1)" if any(x in n for x in (".fq", ".fk", ".fv", ".fo", ".R0", ".R1")) else "embed_in")
|
| 481 |
+
out[g] = out.get(g, 0) + p.numel()
|
| 482 |
+
out["TOTAL"] = sum(v for v in out.values())
|
| 483 |
+
out["trainable"] = sum(p.numel() for p in m.parameters() if p.requires_grad)
|
| 484 |
+
return dict(sorted(out.items()))
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def rmsnorm_fp32():
|
| 488 |
+
"""bf16 autocast: nn.RMSNorm (fp32 weights) on a bf16 input falls off the fused kernel (dtype-mismatch warning). Run it in fp32 on
|
| 489 |
+
the fused kernel and cast the result back to the input dtype."""
|
| 490 |
+
if getattr(nn.RMSNorm, "_fp32_patch", False):
|
| 491 |
+
return
|
| 492 |
+
|
| 493 |
+
def fwd(self, x):
|
| 494 |
+
with torch.autocast("cuda", enabled=False):
|
| 495 |
+
return F.rms_norm(x.float(), self.normalized_shape, self.weight, self.eps).to(x.dtype)
|
| 496 |
+
nn.RMSNorm.forward = fwd; nn.RMSNorm._fp32_patch = True
|
arctag/_core/engine.py
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc Tagger live demo: checkpoint manager (hot reload of the newest ckpt/AT10M/step<N>) + per-session streaming engine.
|
| 2 |
+
|
| 3 |
+
ArcSession wraps code/fast_arc.FastArc (the parity-tested streaming engine; snapshot revision, T0 / T1 / T2 delayed read-outs)
|
| 4 |
+
and turns its raw logits into UI-ready objects:
|
| 5 |
+
spans per tier, decoded from span_beg / span_in / span_end (W tags: entities, numbers, PII, profanity, toxicity), with the
|
| 6 |
+
span verification head (model.ver) and, when a session KB is given, the KB pointer (model.kb_logits) for norm tags
|
| 7 |
+
events ev_at / ev_imm / ev_rec above threshold, peak-picked (speaker change, turn yield, command complete, sports, legal, meeting)
|
| 8 |
+
utt running pooled utt / speaker / item read-outs every word, final ones at the endpoint (intent, act, safety U tags, role)
|
| 9 |
+
frame F / G states of the newest word (content mode / phase / game state / subject)
|
| 10 |
+
Thresholds: step config.json["thresholds"][tag@q][fine domain] with a fallback chain (see Thresholds).
|
| 11 |
+
CPU only; nothing here touches the GPU or writes into the checkpoint dir.
|
| 12 |
+
"""
|
| 13 |
+
import glob, json, math, os, re, sys, threading, time
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")
|
| 17 |
+
import torch
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
|
| 20 |
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 21 |
+
B = os.path.dirname(HERE)
|
| 22 |
+
CODE = HERE # [release] vendored modules live next to engine.py
|
| 23 |
+
sys.path.insert(0, CODE)
|
| 24 |
+
import arc_text as AT # noqa: E402
|
| 25 |
+
import stream_arc as SA # noqa: E402
|
| 26 |
+
import fast_arc as FA # noqa: E402
|
| 27 |
+
from tagtable import TextTable, FINE_DOMAINS, domain_index # noqa: E402
|
| 28 |
+
from losses import GroupIndex # noqa: E402
|
| 29 |
+
from arc_text import POOLED # noqa: E402
|
| 30 |
+
|
| 31 |
+
PROD_CKPT = os.path.join(B, "ckpt", "AT10M")
|
| 32 |
+
CKPT_DIR = os.environ.get("ARCTAG_CKPT", PROD_CKPT)
|
| 33 |
+
# prod stays on ckpt/AT10M (v2 pilots live in other dirs): another dir on prod needs the explicit ARCTAG_CKPT_SWITCH=1
|
| 34 |
+
if os.environ.get("ARCTAG_ROLE", "prod") == "prod" and os.path.realpath(CKPT_DIR) != os.path.realpath(PROD_CKPT) \
|
| 35 |
+
and os.environ.get("ARCTAG_CKPT_SWITCH") != "1":
|
| 36 |
+
raise RuntimeError(f"prod ckpt dir must be {PROD_CKPT} (got {CKPT_DIR}); set ARCTAG_CKPT_SWITCH=1 to switch explicitly")
|
| 37 |
+
# hot reload never changes the model architecture (v1 span_in <-> v2 span head) unless ARCTAG_ARCH_SWITCH=1 (then restart instead)
|
| 38 |
+
ARCH_SWITCH = os.environ.get("ARCTAG_ARCH_SWITCH") == "1"
|
| 39 |
+
# v2 checkpoints: display spans / redaction from the span head. auto (default): per family, the head where its dev exact F1 (T0)
|
| 40 |
+
# is >= the span_in typed decode's - HEAD_TOL (eval.json / ARCTAG_SPAN_THR dev_exact_f1), span_in otherwise; 1 = every family; 0 = never
|
| 41 |
+
SPAN_HEAD_MODE = os.environ.get("ARCTAG_SPAN_HEAD", "auto")
|
| 42 |
+
SPAN_HEAD = SPAN_HEAD_MODE != "0"
|
| 43 |
+
HEAD_TOL = 0.05 # the dev "fam:" decode is span_in at its oracle threshold, an upper bound of the demo's span_in path (pilot step2000
|
| 44 |
+
# probe set: head better for number / pii / profanity / toxicity at dev deficits <= .03, worse for mentions at -.09)
|
| 45 |
+
# ARCTAG_SPAN_THR=<json with thresholds_span> (staging tests: a checkpoint whose eval has not written thresholds_span yet)
|
| 46 |
+
SPAN_THR_FILE = json.load(open(os.environ["ARCTAG_SPAN_THR"])) if os.environ.get("ARCTAG_SPAN_THR") else {}
|
| 47 |
+
|
| 48 |
+
# UI domain -> fine domain (thresholds are keyed by fine domains) + fallbacks for missing threshold cells
|
| 49 |
+
UI_DOMAINS = {"auto": ["unknown"], "talk": ["talk", "stream_chat", "news"], "meeting": ["meeting"],
|
| 50 |
+
"court": ["court_appellate", "court_trial"], "assistant": ["assistant", "assistant_noisy"],
|
| 51 |
+
"sports": ["nba", "nhl", "nfl", "soccer", "cricket"], "news": ["news", "talk"]}
|
| 52 |
+
|
| 53 |
+
# fine engine domain within a vertical (FiLM / adapters / validity / thresholds are per fine domain; the UI only picks the vertical):
|
| 54 |
+
# lexicon cues over the words so far, decided once the best domain has FINE_MIN hits and FINE_RATIO x the runner-up
|
| 55 |
+
FINE_CUES = {
|
| 56 |
+
"sports": {
|
| 57 |
+
"nba": "basketball nba rebound rebounds dunk layup three-pointer threes jumper assist assists paint free-throw buzzer "
|
| 58 |
+
"lakers celtics warriors knicks bulls heat nuggets bucks suns mavericks clippers rockets spurs nets sixers raptors "
|
| 59 |
+
"dribble backcourt fourth-quarter timeout",
|
| 60 |
+
"nhl": "hockey nhl puck goalie power-play penalty-kill slapshot slap faceoff icing blue-line rink skate skates "
|
| 61 |
+
"bruins rangers penguins maple leafs canadiens blackhawks oilers flames capitals lightning stick",
|
| 62 |
+
"nfl": "nfl football touchdown quarterback yard yards field-goal end-zone interception sack sacks fumble punt kickoff "
|
| 63 |
+
"linebacker receiver tight-end scrimmage patriots cowboys packers chiefs eagles steelers 49ers broncos down-and",
|
| 64 |
+
"soccer": "soccer goalkeeper keeper offside midfield midfielder striker penalty-area corner free-kick pitch premier "
|
| 65 |
+
"league champions-league fifa uefa header crossbar extra-time stoppage arsenal chelsea liverpool madrid "
|
| 66 |
+
"barcelona united city tottenham",
|
| 67 |
+
"cricket": "cricket wicket wickets bowler bowling batsman batter innings over overs boundary six sixes lbw stumps "
|
| 68 |
+
"crease spinner pace yorker bouncer test odi t20 ipl umpire maiden run-out century fifty",
|
| 69 |
+
},
|
| 70 |
+
"court": {
|
| 71 |
+
"court_appellate": "chief-justice justice justices petitioner petitioners respondent respondents argument amicus "
|
| 72 |
+
"certiorari circuit appellate appeal brief briefs precedent statute statutory may-it-please "
|
| 73 |
+
"constitutional rebuttal counsel",
|
| 74 |
+
"court_trial": "witness witnesses objection sustained overruled jury jurors exhibit cross-examination direct-examination "
|
| 75 |
+
"redirect testimony testify testified sworn defendant plaintiff prosecution verdict sidebar approach "
|
| 76 |
+
"bailiff deposition",
|
| 77 |
+
},
|
| 78 |
+
}
|
| 79 |
+
FINE_MIN, FINE_RATIO = 4, 2.0
|
| 80 |
+
FINE_REDOMAIN = os.environ.get("ARCTAG_FINE_REDOMAIN", "1") != "0"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _fine_index():
|
| 84 |
+
ix = {}
|
| 85 |
+
for ui, d in FINE_CUES.items():
|
| 86 |
+
for fine, cues in d.items():
|
| 87 |
+
for c in cues.split():
|
| 88 |
+
ix.setdefault(ui, {})[tuple(c.replace("-", " ").split())] = fine
|
| 89 |
+
return ix
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
FINE_IX = _fine_index()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def fine_hint(ui, words, last=3000):
|
| 96 |
+
"""the fine domain the words' lexicon cues point to within UI domain `ui` (sports / court), or None (undecided / other ui)"""
|
| 97 |
+
ix = FINE_IX.get(ui)
|
| 98 |
+
if not ix or not words:
|
| 99 |
+
return None
|
| 100 |
+
toks = [re.sub(r"[^a-z0-9]", "", w["w"].lower()) for w in words[-last:]]
|
| 101 |
+
cnt = {}
|
| 102 |
+
for i in range(len(toks)):
|
| 103 |
+
for L in (1, 2, 3):
|
| 104 |
+
f = ix.get(tuple(toks[i:i + L])) if i + L <= len(toks) else None
|
| 105 |
+
if f:
|
| 106 |
+
cnt[f] = cnt.get(f, 0) + 1
|
| 107 |
+
if not cnt:
|
| 108 |
+
return None
|
| 109 |
+
r = sorted(cnt.items(), key=lambda x: -x[1])
|
| 110 |
+
if r[0][1] >= FINE_MIN and (len(r) == 1 or r[0][1] >= FINE_RATIO * r[1][1]):
|
| 111 |
+
return r[0][0]
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
FAMILY = [("pii", ("safety.pii.",)), ("profanity", ("safety.profanity.", "safety.toxicity.")),
|
| 116 |
+
("number", ("entity.number.", "entity.numeral")), ("entity", ("entity.mention.",))]
|
| 117 |
+
SKIP_PREFIX = ("affect.",) # not trained in AT10M (DESIGN 12: masked)
|
| 118 |
+
STATE_GROUPS = ["content.mode", "content.game_state", "content.legal_phase", "content.meeting_phase", "content.subject"]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
DECODE_FAM = [("number", ("entity.number.",)), ("entity", ("entity.mention.",)), ("pii", ("safety.pii.",)),
|
| 122 |
+
("profanity", ("safety.profanity.",)), ("toxicity", ("safety.toxicity.",))]
|
| 123 |
+
SPAN_EXCL = os.environ.get("ARCTAG_SPAN_EXCL", "1") != "0" # type-exclusive span decode per family (ARCTAG_SPAN_EXCL=0: per-tag runs)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
UMBRELLA = ("entity.numeral", "safety.toxicity.toxic_any") # co-labelled WITH a subtype in the gold (82 of 253 news number spans
|
| 127 |
+
# overlap a numeral): never merged, never compete with the subtypes
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def decode_family(tag):
|
| 131 |
+
if tag in UMBRELLA:
|
| 132 |
+
return None
|
| 133 |
+
for fam, pre in DECODE_FAM:
|
| 134 |
+
if tag.startswith(pre):
|
| 135 |
+
return fam
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def family_of(tag):
|
| 140 |
+
for fam, pre in FAMILY:
|
| 141 |
+
if tag.startswith(pre):
|
| 142 |
+
return fam
|
| 143 |
+
return "other"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def list_steps(d=CKPT_DIR):
|
| 147 |
+
out = []
|
| 148 |
+
for p in glob.glob(os.path.join(d, "step*")):
|
| 149 |
+
m = re.fullmatch(r"step(\d+)", os.path.basename(p))
|
| 150 |
+
f = os.path.join(p, "model.safetensors")
|
| 151 |
+
if m and os.path.exists(f):
|
| 152 |
+
out.append((int(m.group(1)), p))
|
| 153 |
+
return sorted(out)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
PROD_THR_DIR = os.environ.get("ARCTAG_PROD_THR", os.path.join(HERE, "prod_thresholds"))
|
| 157 |
+
DISPLAY_TIER = os.environ.get("ARCTAG_DISPLAY_TIER", "T0")
|
| 158 |
+
AUTO_REDOMAIN = os.environ.get("ARCTAG_AUTO_REDOMAIN", "1") != "0" # auto: re-condition the engine on the detected vertical's domain # tier the demo decodes spans / events / states from by default
|
| 159 |
+
SAFETY_DOMS = {"talk", "stream_chat", "short_clips", "esports"} # fine domains of the safety verticals (contact centre, T&S, gaming)
|
| 160 |
+
CLAMP_SPAN = 0.3 # display floor of span_beg / span_in thresholds (T0-fitted values of rare tags can be tiny)
|
| 161 |
+
CLAMP_TOX = 0.5 # safety.toxicity.* span / utt floor outside the safety verticals (news, sports, meetings, legal, auto)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
SAME_VERT = [{"nba", "nhl", "nfl", "cricket", "soccer"}, {"court_appellate", "court_trial"}, {"assistant", "assistant_noisy"}]
|
| 165 |
+
THR_FALLBACK = os.environ.get("ARCTAG_THR_FALLBACK", "pooled") # pooled (default) | sibling (pre-19:05 behaviour)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def lookup_thr(v, doms):
|
| 169 |
+
"""one threshold cell {fine domain | 'unknown' | '_all': thr} for the session's fine domains (doms[0] = primary).
|
| 170 |
+
Tables with a pooled '_all' (the demo's T0 refit): the primary domain or a same-vertical sibling (sports leagues, the two
|
| 171 |
+
court domains, assistant / assistant_noisy), else the POOLED value (a domain with < 10 dev positives has no key; a sibling
|
| 172 |
+
from another vertical, e.g. news -> talk, is not a valid stand-in). Other tables: doms -> 'unknown' -> median."""
|
| 173 |
+
if not v:
|
| 174 |
+
return None
|
| 175 |
+
if THR_FALLBACK == "pooled" and "_all" in v:
|
| 176 |
+
d0 = doms[0] if doms else "unknown"
|
| 177 |
+
sib = next((g for g in SAME_VERT if d0 in g), {d0})
|
| 178 |
+
for d in [d0] + [d for d in doms if d in sib and d != d0]:
|
| 179 |
+
if d in v:
|
| 180 |
+
return float(v[d])
|
| 181 |
+
return float(v["_all"])
|
| 182 |
+
for d in list(doms) + ["unknown", "_all"]:
|
| 183 |
+
if d in v:
|
| 184 |
+
return float(v[d])
|
| 185 |
+
return float(np.median(list(v.values())))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def clamp_thr(col, doms, x):
|
| 189 |
+
"""display clamps on a looked-up threshold (doms = the fine domains of the session, first = primary)"""
|
| 190 |
+
tag, _, q = col.rpartition("@")
|
| 191 |
+
if q in ("span_in", "span_beg"):
|
| 192 |
+
x = max(x, CLAMP_SPAN)
|
| 193 |
+
if tag.startswith("safety.toxicity.") and (not doms or doms[0] not in SAFETY_DOMS):
|
| 194 |
+
x = max(x, CLAMP_TOX)
|
| 195 |
+
return x
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def load_prod_thresholds(step, d=None):
|
| 199 |
+
"""demo/prod_thresholds/step<N>.json (T0-fitted on dev, refit_thr) for the newest N <= step -> (thresholds, N) or ({}, None)"""
|
| 200 |
+
d = d or PROD_THR_DIR
|
| 201 |
+
best = None
|
| 202 |
+
for p in glob.glob(os.path.join(d, "step*.json")):
|
| 203 |
+
m = re.fullmatch(r"step(\d+)\.json", os.path.basename(p))
|
| 204 |
+
if m and int(m.group(1)) <= step and (best is None or int(m.group(1)) > best[0]):
|
| 205 |
+
best = (int(m.group(1)), p)
|
| 206 |
+
if best is None:
|
| 207 |
+
return {}, None
|
| 208 |
+
try:
|
| 209 |
+
return json.load(open(best[1])).get("thresholds") or {}, best[0]
|
| 210 |
+
except Exception:
|
| 211 |
+
return {}, None
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def load_event_tiers(step, d=None):
|
| 215 |
+
"""demo/prod_thresholds/events_step<N>.json (newest N <= step; tests ev_tier fit): {"tier": {tag: T0|T1}, "hide": [tags],
|
| 216 |
+
"thresholds": {tag@q: {domain|_all: thr}} fitted AT each tag's tier} -> (doc, N) or ({}, None)"""
|
| 217 |
+
d = d or PROD_THR_DIR
|
| 218 |
+
best = None
|
| 219 |
+
for p in glob.glob(os.path.join(d, "events_step*.json")):
|
| 220 |
+
m = re.fullmatch(r"events_step(\d+)\.json", os.path.basename(p))
|
| 221 |
+
if m and int(m.group(1)) <= step and (best is None or int(m.group(1)) > best[0]):
|
| 222 |
+
best = (int(m.group(1)), p)
|
| 223 |
+
if best is None:
|
| 224 |
+
return {}, None
|
| 225 |
+
try:
|
| 226 |
+
return json.load(open(best[1])), best[0]
|
| 227 |
+
except Exception:
|
| 228 |
+
return {}, None
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class Thresholds:
|
| 232 |
+
"""thr(tag@q, ui_domain): the demo's T0-fitted table (prod_thresholds/step<=N>.json) -> the step's own per-domain value ->
|
| 233 |
+
the newest older step that has thresholds; within each table: the fine domains of the UI domain -> 'unknown' -> '_all'
|
| 234 |
+
(pooled) -> median over the domains it has; default 0.5. Display clamps (clamp_thr) on top."""
|
| 235 |
+
def __init__(self, own, older=None, older_step=None, default=0.5, pref=None, pref_step=None):
|
| 236 |
+
self.own = own or {}; self.older = older or {}; self.older_step = older_step; self.default = default
|
| 237 |
+
self.pref = pref or {}; self.pref_step = pref_step
|
| 238 |
+
self.ev = {}; self.ev_step = None # per-event display tier + tier-fitted event thresholds (load_event_tiers)
|
| 239 |
+
|
| 240 |
+
def set_events(self, doc, step):
|
| 241 |
+
self.ev = doc or {}; self.ev_step = step
|
| 242 |
+
if self.ev.get("thresholds"): # event thresholds fitted at the event's tier take precedence
|
| 243 |
+
self.pref = dict(self.pref, **self.ev["thresholds"])
|
| 244 |
+
|
| 245 |
+
def ev_tier(self, tag, default="T0"):
|
| 246 |
+
return (self.ev.get("tier") or {}).get(tag, default)
|
| 247 |
+
|
| 248 |
+
def ev_hidden(self, tag):
|
| 249 |
+
return tag in set(self.ev.get("hide") or ())
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _look(th, col, doms):
|
| 253 |
+
return lookup_thr(th.get(col), doms)
|
| 254 |
+
|
| 255 |
+
def tables(self):
|
| 256 |
+
return [t for t in (self.pref, self.own, self.older) if t]
|
| 257 |
+
|
| 258 |
+
def __call__(self, col, ui_dom):
|
| 259 |
+
doms = list(ui_dom) if isinstance(ui_dom, (list, tuple)) else UI_DOMAINS.get(ui_dom, ["unknown"])
|
| 260 |
+
x = None
|
| 261 |
+
for th in self.tables():
|
| 262 |
+
x = self._look(th, col, doms)
|
| 263 |
+
if x is not None:
|
| 264 |
+
break
|
| 265 |
+
return clamp_thr(col, doms, self.default if x is None else x)
|
| 266 |
+
|
| 267 |
+
def source(self):
|
| 268 |
+
s = f"prod_thresholds/step{self.pref_step} (T0) + " if self.pref_step is not None else ""
|
| 269 |
+
if self.ev_step is not None:
|
| 270 |
+
s += f"events_step{self.ev_step} + "
|
| 271 |
+
if self.own:
|
| 272 |
+
return s + "own"
|
| 273 |
+
return s + (f"step{self.older_step}" if self.older else "default 0.5")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class Loaded:
|
| 277 |
+
def __init__(self, step, path, model, table, thr, cfg):
|
| 278 |
+
self.step, self.path, self.model, self.table, self.thr, self.cfg = step, path, model, table, thr, cfg
|
| 279 |
+
self.GI = GroupIndex(table)
|
| 280 |
+
self.has_kb = hasattr(model, "ent") and hasattr(model.ent, "ptr_logits")
|
| 281 |
+
self.has_head = hasattr(model, "span_head")
|
| 282 |
+
self.arch = tuple(sorted(k for k in ("span_head", "gat", "dattn", "dmix", "kb_spot", "pii_imm", "spk_link") if hasattr(model, k)))
|
| 283 |
+
self.head_tags = {f: list(v[2]) for f, v in model.span_head.fm.items() if v[0]} if self.has_head else {}
|
| 284 |
+
self.head_use, self.head_why = self._head_families()
|
| 285 |
+
self.loaded_at = time.time()
|
| 286 |
+
|
| 287 |
+
def _head_families(self):
|
| 288 |
+
"""families decoded from the span head (see SPAN_HEAD_MODE) + why"""
|
| 289 |
+
if not self.head_tags or SPAN_HEAD_MODE == "0":
|
| 290 |
+
return set(), "off"
|
| 291 |
+
if SPAN_HEAD_MODE == "1":
|
| 292 |
+
return set(self.head_tags), "all"
|
| 293 |
+
f1 = SPAN_THR_FILE.get("dev_exact_f1", {}).get("T0") if SPAN_THR_FILE.get("dev_exact_f1") else None
|
| 294 |
+
if f1 is None:
|
| 295 |
+
try:
|
| 296 |
+
sp = json.load(open(os.path.join(self.path, "eval.json")))["dev"]["span_f1"]["spans"]["T0"]
|
| 297 |
+
f1 = {k: (v.get("all") or {}).get("exact_f1") for k, v in sp.items()}
|
| 298 |
+
except Exception:
|
| 299 |
+
f1 = None
|
| 300 |
+
if not f1:
|
| 301 |
+
return set(self.head_tags), "all (no dev F1)"
|
| 302 |
+
use = {f for f in self.head_tags if f1.get("head:" + f) is not None and (f1.get("fam:" + f) is None or f1["head:" + f] >= f1["fam:" + f] - HEAD_TOL)}
|
| 303 |
+
return use, {f: (f1.get("head:" + f), f1.get("fam:" + f)) for f in self.head_tags}
|
| 304 |
+
|
| 305 |
+
def head_thr(self, tier, fam):
|
| 306 |
+
"""v2 span-head threshold on 1 - P(O) for a family (config.json thresholds_span {tier: {head:<family>: th}}, dev-fitted
|
| 307 |
+
by the trainer's eval), tier -> T0 -> 0.5"""
|
| 308 |
+
ts = (self.cfg or {}).get("thresholds_span") or SPAN_THR_FILE.get("thresholds_span") or {}
|
| 309 |
+
for t in (tier, "T0"):
|
| 310 |
+
v = (ts.get(t) or {}).get("head:" + fam)
|
| 311 |
+
if v is not None:
|
| 312 |
+
return float(v)
|
| 313 |
+
return 0.5
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
class ModelManager:
|
| 317 |
+
"""loads the newest complete step; a watcher thread polls for newer ones (file size stable + loadable) and swaps them in."""
|
| 318 |
+
def __init__(self, ckpt_dir=CKPT_DIR, poll_s=20.0, log=print):
|
| 319 |
+
self.dir = ckpt_dir; self.poll = poll_s; self.log = log
|
| 320 |
+
self.table = TextTable(); self.table.load_E()
|
| 321 |
+
self.cur = None; self.lock = threading.Lock(); self.listeners = []; self._sizes = {}
|
| 322 |
+
steps = list_steps(ckpt_dir)
|
| 323 |
+
if not steps:
|
| 324 |
+
raise RuntimeError(f"no step*/model.safetensors in {ckpt_dir}")
|
| 325 |
+
for st, p in reversed(steps): # newest that loads
|
| 326 |
+
try:
|
| 327 |
+
self.cur = self._load(st, p); break
|
| 328 |
+
except Exception as e: # partially written -> try older
|
| 329 |
+
self.log(f"[ckpt] step{st} not loadable yet: {e!r}")
|
| 330 |
+
if self.cur is None:
|
| 331 |
+
raise RuntimeError("no loadable checkpoint")
|
| 332 |
+
self.log(f"[ckpt] loaded step{self.cur.step} (thresholds: {self.cur.thr.source()})"
|
| 333 |
+
+ (f" span head: {sorted(self.cur.head_use)} ({self.cur.head_why})" if self.cur.has_head else ""))
|
| 334 |
+
|
| 335 |
+
def _thresholds(self, step):
|
| 336 |
+
own = {}
|
| 337 |
+
cj = os.path.join(self.dir, f"step{step}", "config.json")
|
| 338 |
+
try:
|
| 339 |
+
own = json.load(open(cj)).get("thresholds") or {}
|
| 340 |
+
except Exception:
|
| 341 |
+
pass
|
| 342 |
+
older, ostep = {}, None
|
| 343 |
+
if not own:
|
| 344 |
+
for st, p in reversed(list_steps(self.dir)):
|
| 345 |
+
if st >= step:
|
| 346 |
+
continue
|
| 347 |
+
try:
|
| 348 |
+
th = json.load(open(os.path.join(p, "config.json"))).get("thresholds") or {}
|
| 349 |
+
except Exception:
|
| 350 |
+
th = {}
|
| 351 |
+
if th:
|
| 352 |
+
older, ostep = th, st; break
|
| 353 |
+
pref, pstep = load_prod_thresholds(step)
|
| 354 |
+
t = Thresholds(own, older, ostep, pref=pref, pref_step=pstep)
|
| 355 |
+
t.set_events(*load_event_tiers(step))
|
| 356 |
+
return t
|
| 357 |
+
|
| 358 |
+
def _load(self, step, path):
|
| 359 |
+
from safetensors.torch import load_file
|
| 360 |
+
cfg = {}
|
| 361 |
+
try:
|
| 362 |
+
cfg = json.load(open(os.path.join(path, "config.json")))
|
| 363 |
+
except Exception:
|
| 364 |
+
pass
|
| 365 |
+
sc = cfg.get("scratch") or AT.SCRATCH_10M
|
| 366 |
+
m = AT.ArcText(self.table, scratch=sc)
|
| 367 |
+
sd = load_file(os.path.join(path, "model.safetensors"))
|
| 368 |
+
SA.add_modules_for(m, sd) # every stage-2 / v2 module the keys imply (kb_long, redact, spk_link, v2)
|
| 369 |
+
missing, unexpected = m.load_state_dict(sd, strict=False)
|
| 370 |
+
if unexpected:
|
| 371 |
+
raise RuntimeError(f"step{step}: checkpoint keys without a module: {unexpected[:6]}")
|
| 372 |
+
bad = [k for k in missing if not k.startswith(("head.E",))]
|
| 373 |
+
if bad:
|
| 374 |
+
self.log(f"[ckpt] step{step}: {len(bad)} missing keys (e.g. {bad[:3]})")
|
| 375 |
+
m.eval()
|
| 376 |
+
return Loaded(step, path, m, self.table, self._thresholds(step), cfg)
|
| 377 |
+
|
| 378 |
+
def refresh_thresholds(self):
|
| 379 |
+
"""the async eval writes thresholds into config.json after the step appears: pick them up"""
|
| 380 |
+
c = self.cur
|
| 381 |
+
t = self._thresholds(c.step)
|
| 382 |
+
if (bool(t.own) != bool(c.thr.own)) or (t.older_step != c.thr.older_step) or (t.pref_step != c.thr.pref_step) \
|
| 383 |
+
or (t.ev_step != c.thr.ev_step):
|
| 384 |
+
c.thr = t; return True
|
| 385 |
+
return False
|
| 386 |
+
|
| 387 |
+
def check_new(self):
|
| 388 |
+
steps = list_steps(self.dir)
|
| 389 |
+
changed = self.refresh_thresholds()
|
| 390 |
+
if not steps or steps[-1][0] <= self.cur.step:
|
| 391 |
+
return changed
|
| 392 |
+
st, p = steps[-1]
|
| 393 |
+
f = os.path.join(p, "model.safetensors")
|
| 394 |
+
sz = os.path.getsize(f); prev = self._sizes.get(st); self._sizes[st] = sz
|
| 395 |
+
if prev != sz or time.time() - os.path.getmtime(f) < 30:
|
| 396 |
+
return changed # still being written: wait for a stable size
|
| 397 |
+
if self._sizes.get(("arch", st)):
|
| 398 |
+
return changed # already rejected (architecture change): do not reload it every poll
|
| 399 |
+
try:
|
| 400 |
+
new = self._load(st, p)
|
| 401 |
+
except Exception as e:
|
| 402 |
+
self.log(f"[ckpt] step{st} load failed: {e!r}"); return changed
|
| 403 |
+
if new.arch != self.cur.arch and not ARCH_SWITCH:
|
| 404 |
+
if self._sizes.get(("arch", st)) is None:
|
| 405 |
+
self.log(f"[ckpt] step{st}: architecture {new.arch} != running {self.cur.arch}: NOT hot-reloaded (ARCTAG_ARCH_SWITCH=1 + restart)")
|
| 406 |
+
self._sizes[("arch", st)] = 1
|
| 407 |
+
return changed
|
| 408 |
+
with self.lock:
|
| 409 |
+
self.cur = new
|
| 410 |
+
self.log(f"[ckpt] hot-reloaded step{st} (thresholds: {new.thr.source()})")
|
| 411 |
+
for cb in list(self.listeners):
|
| 412 |
+
try:
|
| 413 |
+
cb(new)
|
| 414 |
+
except Exception as e:
|
| 415 |
+
self.log(f"[ckpt] listener error {e!r}")
|
| 416 |
+
return True
|
| 417 |
+
|
| 418 |
+
def watch(self, stop_evt, on_change=None):
|
| 419 |
+
while not stop_evt.wait(self.poll):
|
| 420 |
+
try:
|
| 421 |
+
if self.check_new() and on_change:
|
| 422 |
+
on_change(self.cur)
|
| 423 |
+
except Exception as e:
|
| 424 |
+
self.log(f"[ckpt] watch error {e!r}")
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
# ---------------------------------------------------------------------------------------------------------------- session
|
| 428 |
+
class ArcSession:
|
| 429 |
+
"""one live stream: provider-agnostic events (adapters.py schema) -> FastArc -> UI updates."""
|
| 430 |
+
SPAN_WIN = 96 # words re-decoded per update (revisions never reach further back than FastArc.KEEP = 64)
|
| 431 |
+
|
| 432 |
+
def __init__(self, loaded, ui_domain="auto", asr="deepgram", kb=None, int8=False, floor=0.0):
|
| 433 |
+
self.ui_domain = ui_domain if ui_domain in UI_DOMAINS else "auto"
|
| 434 |
+
self.fine = UI_DOMAINS[self.ui_domain][0]; self.asr = asr; self.int8 = int8; self.floor = float(floor or 0.0)
|
| 435 |
+
self.kb_names = [k.strip() for k in (kb or []) if k and k.strip()][:256]
|
| 436 |
+
self.words = [] # provider word dicts (ours; mirror of engine.words)
|
| 437 |
+
self.utt_bounds = [] # word indices where an utterance ended (endpoint at k)
|
| 438 |
+
self.events = []; self._ev_keys = set(); self.alerts = []; self._al_keys = set()
|
| 439 |
+
self.utt_final = [] # finished utterances: {w0, w1, readouts}
|
| 440 |
+
self.lat = [] # (ms, n_words) per engine update
|
| 441 |
+
self._spksrc = None # stage-2 / v2 input "spksrc" (0 no speaker ids, 1 gold, 2 ASR-like): set at the first words
|
| 442 |
+
self._bind(loaded)
|
| 443 |
+
|
| 444 |
+
# ---- model binding (also used by hot reload: rebuild + replay)
|
| 445 |
+
def _bind(self, loaded):
|
| 446 |
+
self.L = loaded; T = loaded.table
|
| 447 |
+
self.e = FA.FastArc(loaded.model, T, domain=self.fine, asr=self.asr)
|
| 448 |
+
if self.int8:
|
| 449 |
+
FA.quantize_fast(self.e)
|
| 450 |
+
self._set_xfeat()
|
| 451 |
+
self.store = {"T0": {}, "T1": {}, "T2": {}} # (word) -> {q: probs np}
|
| 452 |
+
self.pooled_now = None
|
| 453 |
+
# column metadata per read-out
|
| 454 |
+
self.cn = {q: [n.split("@")[0] for n in T.col_names(q)] for q in ("frame", "span_beg", "span_in", "span_end", "ev_at", "ev_imm", "ev_rec", "utt", "speaker", "item")}
|
| 455 |
+
thr = loaded.thr; d = self.doms
|
| 456 |
+
self.th_raw = {q: np.array([thr(f"{t}@{q}", d) for t in names], np.float32) for q, names in self.cn.items()}
|
| 457 |
+
self.th = {q: np.maximum(v, self.floor) for q, v in self.th_raw.items()} # optional display floor (UI), 0 = pure per-domain
|
| 458 |
+
valid = T.valid[domain_index(self.fine)]
|
| 459 |
+
self.valid = {q: np.array([bool(valid[T.idx[t]]) for t in names]) for q, names in self.cn.items()}
|
| 460 |
+
bi = {t: i for i, t in enumerate(self.cn["span_beg"])}
|
| 461 |
+
self.beg_of_in = np.array([bi.get(t, -1) for t in self.cn["span_in"]])
|
| 462 |
+
norm = {T.ids[i] for i in T.norm_tags}
|
| 463 |
+
self.is_norm = np.array([t in norm for t in self.cn["span_in"]])
|
| 464 |
+
self.kbe = None
|
| 465 |
+
if self.kb_names and loaded.has_kb:
|
| 466 |
+
with torch.inference_mode():
|
| 467 |
+
self.kbe = loaded.model.kb_embed(self.kb_names)
|
| 468 |
+
|
| 469 |
+
def _set_xfeat(self):
|
| 470 |
+
"""session-constant stage-2 inputs: spksrc as the trainer's data.Windows sets it (2 = ASR-like ids, the live providers'
|
| 471 |
+
diarization; 0 = no ids) and turn = 0 (no Flux turn events). Without it FastArc embeds spksrc 0 for every word while the
|
| 472 |
+
model saw 2 on ASR sessions. ARCTAG_SPKSRC overrides (parity on gold-id sessions: 1)."""
|
| 473 |
+
if self._spksrc is not None and ("spksrc" in self.e.feat or "turn" in self.e.feat):
|
| 474 |
+
self.e.xfeat = {"spksrc": self._spksrc, "turn": 0}
|
| 475 |
+
|
| 476 |
+
def set_kb(self, names):
|
| 477 |
+
self.kb_names = [k.strip() for k in (names or []) if k and k.strip()][:256]
|
| 478 |
+
self.kbe = None
|
| 479 |
+
if self.kb_names and self.L.has_kb:
|
| 480 |
+
with torch.inference_mode():
|
| 481 |
+
self.kbe = self.L.model.kb_embed(self.kb_names)
|
| 482 |
+
|
| 483 |
+
def rebind(self, loaded):
|
| 484 |
+
"""hot reload: new FastArc on the new weights, replay the session utterance by utterance (one chunk pass each)"""
|
| 485 |
+
ws = [dict(w) for w in self.words]; ub = list(self.utt_bounds)
|
| 486 |
+
self.utt_bounds = []
|
| 487 |
+
self._bind(loaded); self.words = ws
|
| 488 |
+
t = time.perf_counter(); lo = 0
|
| 489 |
+
for k in ub + [len(ws)]:
|
| 490 |
+
if k > lo:
|
| 491 |
+
self._absorb(self.e.update(lo, ws[lo:k]))
|
| 492 |
+
if k in ub:
|
| 493 |
+
self._absorb(self.e.endpoint()); self.utt_bounds.append(k)
|
| 494 |
+
lo = k
|
| 495 |
+
self.words = ws
|
| 496 |
+
return round(1000 * (time.perf_counter() - t), 1)
|
| 497 |
+
|
| 498 |
+
@property
|
| 499 |
+
def doms(self):
|
| 500 |
+
"""threshold domains: the engine's fine domain first, then the UI domain's other codes"""
|
| 501 |
+
return [self.fine] + [d for d in UI_DOMAINS[self.ui_domain] if d != self.fine]
|
| 502 |
+
|
| 503 |
+
def set_domain(self, ui_domain, fine=None):
|
| 504 |
+
"""re-condition the engine on another UI domain (auto mode, after the vertical detector locks): FastArc's FiLM /
|
| 505 |
+
adapters / validity are per fine domain and the model never sees 'unknown' for domain-labelled data, so 'auto' running on
|
| 506 |
+
'unknown' all session shifts every read-out (parity: mean |dp| frame .11, utt max .99). Rebuild + replay (= hot reload).
|
| 507 |
+
Returns the replay ms, or None when unchanged."""
|
| 508 |
+
ui = ui_domain if ui_domain in UI_DOMAINS else "auto"
|
| 509 |
+
fine = fine if fine in UI_DOMAINS[ui] else UI_DOMAINS[ui][0]
|
| 510 |
+
if ui == self.ui_domain and fine == self.fine:
|
| 511 |
+
return None
|
| 512 |
+
self.ui_domain = ui; self.fine = fine
|
| 513 |
+
return self.rebind(self.L)
|
| 514 |
+
|
| 515 |
+
def _update(self, k, ws):
|
| 516 |
+
"""FastArc.update with a snapshot AT k. FastArc snapshots only at chunk starts, so a revision from k replays words[s0:k] +
|
| 517 |
+
ws as one chunk from the nearest older snapshot s0 and leaves none at k. Streams that keep revising from about the same
|
| 518 |
+
point (Flux re-sends the whole turn every Update; nova-3 the whole interim segment) then grow that chunk on every message
|
| 519 |
+
until s0 falls out of the KEEP window, and from then on every update is a full-session replay from word 0 (O(session)
|
| 520 |
+
per message). Running the unchanged prefix words[s0:k] as its own chunk first puts a snapshot at k: revisions stay
|
| 521 |
+
O(revised words). Same outputs (chunked == sequential), both read-out batches are returned."""
|
| 522 |
+
e = self.e
|
| 523 |
+
if k < e.n:
|
| 524 |
+
s0 = max([x for x in e.snap if x <= k], default=None)
|
| 525 |
+
if s0 is None or s0 < k:
|
| 526 |
+
d1 = dict(e.update(k, []))
|
| 527 |
+
d2 = e.update(k, ws)
|
| 528 |
+
d1.update(d2)
|
| 529 |
+
return d1
|
| 530 |
+
return e.update(k, ws)
|
| 531 |
+
|
| 532 |
+
# ---- event application (= adapters.TaggerSession.apply, with timing + UI materialisation)
|
| 533 |
+
def apply(self, evs):
|
| 534 |
+
"""returns (lo, changed) where lo = lowest word index whose display may have changed, or None"""
|
| 535 |
+
lo = None; utt_done = []
|
| 536 |
+
for x in evs:
|
| 537 |
+
t = x["type"]
|
| 538 |
+
if t == "words":
|
| 539 |
+
k = x["from"]; ws = x["words"]; j = 0
|
| 540 |
+
while j < len(ws) and k + j < len(self.e.words) and ws[j] == self.e.words[k + j]:
|
| 541 |
+
j += 1
|
| 542 |
+
if j == len(ws) and k + j == len(self.e.words):
|
| 543 |
+
continue
|
| 544 |
+
k2 = k + j
|
| 545 |
+
if self._spksrc is None and "spksrc" in self.e.feat:
|
| 546 |
+
ov = os.environ.get("ARCTAG_SPKSRC")
|
| 547 |
+
self._spksrc = int(ov) if ov else (2 if any(w.get("spk") is not None for w in ws) else 0)
|
| 548 |
+
self._set_xfeat()
|
| 549 |
+
t0 = time.perf_counter()
|
| 550 |
+
due = self._update(k2, ws[j:])
|
| 551 |
+
self.lat.append((1000 * (time.perf_counter() - t0), max(1, len(self.e.words) - min(k2, len(self.words)))))
|
| 552 |
+
self.words = list(self.e.words)
|
| 553 |
+
self._prune(); self._absorb(due); self._running()
|
| 554 |
+
lo = k2 if lo is None else min(lo, k2)
|
| 555 |
+
elif t == "finalize":
|
| 556 |
+
ws = self.e.words; up = min(x["upto"], len(ws))
|
| 557 |
+
first = next((i for i in range(up) if not ws[i].get("final", True)), None)
|
| 558 |
+
if first is not None:
|
| 559 |
+
t0 = time.perf_counter()
|
| 560 |
+
due = self._update(first, [dict(w, final=True) for w in ws[first:up]] + ws[up:])
|
| 561 |
+
self.lat.append((1000 * (time.perf_counter() - t0), max(1, len(ws) - first)))
|
| 562 |
+
self.words = list(self.e.words)
|
| 563 |
+
self._prune(); self._absorb(due); self._running()
|
| 564 |
+
lo = first if lo is None else min(lo, first)
|
| 565 |
+
elif t == "endpoint":
|
| 566 |
+
if not self.e.n or (self.utt_bounds and self.utt_bounds[-1] >= self.e.n):
|
| 567 |
+
continue
|
| 568 |
+
due = self.e.endpoint()
|
| 569 |
+
self._absorb(due)
|
| 570 |
+
s = self.utt_bounds[-1] if self.utt_bounds else 0
|
| 571 |
+
self.utt_bounds.append(self.e.n)
|
| 572 |
+
ro = self.readouts(self.e.n - 1, final=True)
|
| 573 |
+
u = {"w0": s, "w1": self.e.n, "readouts": ro, "text": " ".join(w["w"] for w in self.words[s:self.e.n])}
|
| 574 |
+
self.utt_final.append(u); utt_done.append(u)
|
| 575 |
+
self._alerts_utt(u)
|
| 576 |
+
lo = s if lo is None else min(lo, s)
|
| 577 |
+
return lo, utt_done
|
| 578 |
+
|
| 579 |
+
def _prune(self):
|
| 580 |
+
for tier, st in self.store.items():
|
| 581 |
+
have = self.e.out[tier]
|
| 582 |
+
for i in [i for i in st if i not in have or i >= self.e.n]:
|
| 583 |
+
del st[i]
|
| 584 |
+
|
| 585 |
+
def _absorb(self, due):
|
| 586 |
+
GI = self.L.GI
|
| 587 |
+
for key, lg in due.items():
|
| 588 |
+
tier, i = key
|
| 589 |
+
if tier == "pooled":
|
| 590 |
+
continue
|
| 591 |
+
P = {}
|
| 592 |
+
for q, z in lg.items():
|
| 593 |
+
if q == "span_head": # v2 per-family (type, BIO) logits -> softmax probabilities
|
| 594 |
+
P[q] = {f: (zt.softmax(-1).numpy(), zb.softmax(-1).numpy()) for f, (zt, zb) in z.items()}
|
| 595 |
+
continue
|
| 596 |
+
P[q] = GI.probs(q, z[None])[0].numpy() if z.numel() else np.zeros(0, np.float32)
|
| 597 |
+
self.store[tier][i] = P
|
| 598 |
+
self._events(tier, i, P)
|
| 599 |
+
|
| 600 |
+
def _running(self):
|
| 601 |
+
if self.e.n:
|
| 602 |
+
self.pooled_now = self.e.n - 1
|
| 603 |
+
|
| 604 |
+
# ---- UI materialisation
|
| 605 |
+
def spans(self, tier, lo=None):
|
| 606 |
+
"""decode W-tag spans of `tier` over the last SPAN_WIN words: runs of span_in >= thr, split where span_beg >= thr
|
| 607 |
+
(a new B inside a run). Returns (a0, spans): every span with end > a0 (the client replaces its spans with end > a0)."""
|
| 608 |
+
st = self.store[tier]; n = self.e.n
|
| 609 |
+
a0 = max(0, n - self.SPAN_WIN)
|
| 610 |
+
idx = [i for i in range(max(0, a0 - 16), n) if i in st]
|
| 611 |
+
if not idx:
|
| 612 |
+
return a0, []
|
| 613 |
+
ON = np.stack([st[i]["span_in"] for i in idx]); PIN = ON
|
| 614 |
+
ON = (ON >= self.th["span_in"]) & self.valid["span_in"]
|
| 615 |
+
bo = self.beg_of_in; hb = bo >= 0
|
| 616 |
+
PB = np.stack([st[i]["span_beg"] for i in idx])
|
| 617 |
+
BEG = np.zeros_like(ON)
|
| 618 |
+
BEG[:, hb] = PB[:, bo[hb]] >= self.th["span_beg"][bo[hb]]
|
| 619 |
+
us = np.array([bool(self.e.ustart[i]) if i < len(self.e.ustart) else False for i in idx])
|
| 620 |
+
BEG[us] = True # spans never cross an utterance start
|
| 621 |
+
head = self._head_cols() if (self.L.has_head and SPAN_HEAD) else {}
|
| 622 |
+
if head:
|
| 623 |
+
ON[:, [c for cs in head.values() for c in cs]] = False # head families: decoded from the span head below
|
| 624 |
+
res = []
|
| 625 |
+
for c in np.nonzero(ON.any(0))[0]:
|
| 626 |
+
a = None
|
| 627 |
+
for r in range(len(idx) + 1):
|
| 628 |
+
on = r < len(idx) and ON[r, c]
|
| 629 |
+
gap = r < len(idx) and r > 0 and idx[r] != idx[r - 1] + 1
|
| 630 |
+
if a is not None and (not on or gap or BEG[r, c]):
|
| 631 |
+
w0, w1 = idx[a], idx[r - 1] + 1
|
| 632 |
+
if w1 > a0:
|
| 633 |
+
res.append((c, w0, w1, float(PIN[a:r, c].max())))
|
| 634 |
+
a = None
|
| 635 |
+
if on and a is None:
|
| 636 |
+
a = r
|
| 637 |
+
alt = {}
|
| 638 |
+
if SPAN_EXCL:
|
| 639 |
+
res, alt = self._exclusive(res, idx, PIN)
|
| 640 |
+
hth = {}
|
| 641 |
+
if head:
|
| 642 |
+
hres, alt2, hth = self._head_spans(tier, idx, us, a0, head)
|
| 643 |
+
res = res + hres; alt.update(alt2)
|
| 644 |
+
out = []
|
| 645 |
+
th_in = self.th["span_in"]
|
| 646 |
+
for c, a, b, p in res:
|
| 647 |
+
tag = self.cn["span_in"][c]
|
| 648 |
+
s = {"tag": tag, "fam": family_of(tag), "a": int(a), "b": int(b), "p": round(p, 3),
|
| 649 |
+
"thr": round(float(hth.get(c, th_in[c])), 3), "text": " ".join(w["w"] for w in self.words[a:b])}
|
| 650 |
+
if c in hth:
|
| 651 |
+
s["src"] = "head"
|
| 652 |
+
if (c, a, b) in alt:
|
| 653 |
+
s["alt"] = alt[(c, a, b)]
|
| 654 |
+
if self.is_norm[c]:
|
| 655 |
+
s.update(self._verify(a, b))
|
| 656 |
+
out.append(s)
|
| 657 |
+
out.sort(key=lambda s: (s["a"], -s["b"]))
|
| 658 |
+
return a0, out
|
| 659 |
+
|
| 660 |
+
def _head_cols(self):
|
| 661 |
+
"""{family: span_in columns of its VALID members} for the v2 span head (cached per binding)"""
|
| 662 |
+
hc = getattr(self, "_hc", None)
|
| 663 |
+
if hc is None or hc[0] is not self.L:
|
| 664 |
+
ci = {t: c for c, t in enumerate(self.cn["span_in"])}
|
| 665 |
+
m = {f: [ci[t] for t in tags if t in ci and self.valid["span_in"][ci[t]]] for f, tags in self.L.head_tags.items()
|
| 666 |
+
if f in self.L.head_use}
|
| 667 |
+
hc = self._hc = (self.L, {f: cs for f, cs in m.items() if cs})
|
| 668 |
+
return hc[1]
|
| 669 |
+
|
| 670 |
+
def head_thr(self, tier, fam):
|
| 671 |
+
f_ = self.L.head_tags.get(fam) or [fam + ".x"]
|
| 672 |
+
return clamp_thr(f"{f_[0]}@span_in", self.doms, max(self.L.head_thr(tier, fam), self.floor))
|
| 673 |
+
|
| 674 |
+
def _head_spans(self, tier, idx, us, a0, head):
|
| 675 |
+
"""v2 span head decode per family over the window: in-span = 1 - P(O) >= th (family threshold). T0 greedy (left to
|
| 676 |
+
right: a span starts at B > I, after a non-span word, a gap or an utterance start); T1 / T2 (read-outs already see the
|
| 677 |
+
delay window) Viterbi over O / B / I with O -> I forbidden, in-states biased by log((1 - th) / th) so a lone word follows
|
| 678 |
+
the same threshold. One type per span: argmax of the summed log type probabilities over the family's VALID members."""
|
| 679 |
+
st = self.store[tier]; ci = {t: c for c, t in enumerate(self.cn["span_in"])}
|
| 680 |
+
res, alt, hth = [], {}, {}
|
| 681 |
+
brk = np.array([bool(us[r]) or (r > 0 and idx[r] != idx[r - 1] + 1) for r in range(len(idx))])
|
| 682 |
+
for f, cols in head.items():
|
| 683 |
+
rows = [r for r, i in enumerate(idx) if "span_head" in st[i] and f in st[i]["span_head"]]
|
| 684 |
+
if len(rows) != len(idx):
|
| 685 |
+
continue
|
| 686 |
+
Pt = np.stack([st[i]["span_head"][f][0] for i in idx]); Pb = np.stack([st[i]["span_head"][f][1] for i in idx])
|
| 687 |
+
tags = self.L.head_tags[f]; mem = [k for k, t in enumerate(tags) if t in ci and ci[t] in cols]
|
| 688 |
+
th = self.head_thr(tier, f); pin = 1.0 - Pb[:, 0]
|
| 689 |
+
if tier == "T0":
|
| 690 |
+
lab = np.where(pin < th, 0, np.where(brk | (Pb[:, 1] > Pb[:, 2]), 1, 2))
|
| 691 |
+
for r in range(1, len(idx)):
|
| 692 |
+
if lab[r] == 2 and lab[r - 1] == 0:
|
| 693 |
+
lab[r] = 1
|
| 694 |
+
if len(idx):
|
| 695 |
+
lab[0] = 1 if lab[0] else 0
|
| 696 |
+
else:
|
| 697 |
+
lab = self._viterbi(Pb, th, brk)
|
| 698 |
+
lt = np.log(np.clip(Pt[:, mem], 1e-9, 1.0))
|
| 699 |
+
r = 0; W = len(idx)
|
| 700 |
+
while r < W:
|
| 701 |
+
if lab[r] == 0:
|
| 702 |
+
r += 1; continue
|
| 703 |
+
e = r + 1
|
| 704 |
+
while e < W and lab[e] == 2:
|
| 705 |
+
e += 1
|
| 706 |
+
w0, w1 = idx[r], idx[e - 1] + 1
|
| 707 |
+
if w1 > a0:
|
| 708 |
+
sc = lt[r:e].sum(0); o = np.argsort(-sc); c = ci[tags[mem[int(o[0])]]]
|
| 709 |
+
res.append((c, w0, w1, float(pin[r:e].max()))); hth[c] = th
|
| 710 |
+
if len(o) > 1:
|
| 711 |
+
pm = Pt[r:e][:, mem].mean(0)
|
| 712 |
+
alt[(c, w0, w1)] = {"tag": tags[mem[int(o[1])]], "p": round(float(pm[o[1]]), 3)}
|
| 713 |
+
r = e
|
| 714 |
+
return res, alt, hth
|
| 715 |
+
|
| 716 |
+
@staticmethod
|
| 717 |
+
def _viterbi(Pb, th, brk):
|
| 718 |
+
"""BIO labels (0 O, 1 B, 2 I) maximising sum of log scores with O -> I and break -> I forbidden: O = log P(O), B / I =
|
| 719 |
+
log P(in) + log((1 - th) / th) + log(P(B or I) / max(P(B), P(I))) (a lone word is in-span exactly when P(in) >= th; B / I
|
| 720 |
+
only costs where the BIO constraint overrides the per-word choice)"""
|
| 721 |
+
W = len(Pb)
|
| 722 |
+
if not W:
|
| 723 |
+
return np.zeros(0, int)
|
| 724 |
+
pin = np.clip(1.0 - Pb[:, 0], 1e-9, 1.0); bias = math.log((1.0 - th) / max(th, 1e-6))
|
| 725 |
+
cb = np.clip(Pb[:, 1:], 1e-9, 1.0); cb = np.log(cb / cb.max(1, keepdims=True)) # B vs I, the better one costs 0
|
| 726 |
+
L = np.empty((W, 3)); L[:, 0] = np.log(np.clip(Pb[:, 0], 1e-9, 1.0)); L[:, 1:] = (np.log(pin) + bias)[:, None] + cb
|
| 727 |
+
NEG = -1e9; dp = np.full((W, 3), NEG); bp = np.zeros((W, 3), int)
|
| 728 |
+
dp[0, 0], dp[0, 1] = L[0, 0], L[0, 1]
|
| 729 |
+
for r in range(1, W):
|
| 730 |
+
p = dp[r - 1]
|
| 731 |
+
for s_ in (0, 1):
|
| 732 |
+
k = int(p.argmax()); dp[r, s_] = p[k] + L[r, s_]; bp[r, s_] = k
|
| 733 |
+
if not brk[r]:
|
| 734 |
+
k = 1 if p[1] >= p[2] else 2; dp[r, 2] = p[k] + L[r, 2]; bp[r, 2] = k
|
| 735 |
+
lab = np.zeros(W, int); lab[-1] = int(dp[-1].argmax())
|
| 736 |
+
for r in range(W - 1, 0, -1):
|
| 737 |
+
lab[r - 1] = bp[r, lab[r]]
|
| 738 |
+
return lab
|
| 739 |
+
|
| 740 |
+
def _exclusive(self, res, idx, PIN):
|
| 741 |
+
"""type-exclusive decode: overlapping spans of one family (number / entity / pii / profanity / toxicity) merge into ONE span
|
| 742 |
+
over their union, typed by the argmax over the family's valid columns of the mean span_in over the span's words; the
|
| 743 |
+
runner-up type is kept ("alt"). Spans of different families may still overlap (pii.private_name + entity.mention.person)."""
|
| 744 |
+
fam_cols = getattr(self, "_fam_cols", None)
|
| 745 |
+
if fam_cols is None:
|
| 746 |
+
fam_cols = self._fam_cols = {}
|
| 747 |
+
for c, t in enumerate(self.cn["span_in"]):
|
| 748 |
+
f = decode_family(t)
|
| 749 |
+
if f and self.valid["span_in"][c]:
|
| 750 |
+
fam_cols.setdefault(f, []).append(c)
|
| 751 |
+
row = {w: r for r, w in enumerate(idx)}
|
| 752 |
+
by = {}; keep = []
|
| 753 |
+
for x in res:
|
| 754 |
+
f = decode_family(self.cn["span_in"][x[0]])
|
| 755 |
+
(by.setdefault(f, []) if f else keep).append(x)
|
| 756 |
+
out = list(keep); alt = {}
|
| 757 |
+
for f, xs in by.items():
|
| 758 |
+
xs.sort(key=lambda x: (x[1], x[2])); groups = []
|
| 759 |
+
for x in xs:
|
| 760 |
+
if groups and x[1] < groups[-1][1]:
|
| 761 |
+
groups[-1][1] = max(groups[-1][1], x[2]); groups[-1][2].append(x)
|
| 762 |
+
else:
|
| 763 |
+
groups.append([x[1], x[2], [x]])
|
| 764 |
+
cols = fam_cols.get(f, [])
|
| 765 |
+
for a, b, mem in groups:
|
| 766 |
+
if len(mem) == 1 or not cols:
|
| 767 |
+
out.append(mem[0]); continue
|
| 768 |
+
rr = [row[w] for w in range(a, b) if w in row]
|
| 769 |
+
M = PIN[rr][:, cols].mean(0)
|
| 770 |
+
o = np.argsort(-M); c = cols[int(o[0])]
|
| 771 |
+
out.append((c, a, b, float(PIN[rr, c].max())))
|
| 772 |
+
if len(o) > 1:
|
| 773 |
+
c2 = cols[int(o[1])]
|
| 774 |
+
alt[(c, a, b)] = {"tag": self.cn["span_in"][c2], "p": round(float(M[o[1]]), 3)}
|
| 775 |
+
return out, alt
|
| 776 |
+
|
| 777 |
+
@torch.inference_mode()
|
| 778 |
+
def _verify(self, a, b):
|
| 779 |
+
"""span verification (+ KB pointer), cached per span: the key holds the word dicts of [a, b) by identity (FastArc
|
| 780 |
+
re-creates them whenever it re-runs those words), so a revision or a re-bind invalidates the entry."""
|
| 781 |
+
e = self.e; ws = e.words[a:b]
|
| 782 |
+
c = getattr(self, "_vcache", None)
|
| 783 |
+
if c is None or c[0] is not e or c[1] is not self.kbe:
|
| 784 |
+
c = self._vcache = (e, self.kbe, {})
|
| 785 |
+
hit = c[2].get((a, b))
|
| 786 |
+
if hit is not None and len(hit[0]) == len(ws) and all(x is y for x, y in zip(hit[0], ws)):
|
| 787 |
+
return dict(hit[1])
|
| 788 |
+
out = self._verify_raw(a, b)
|
| 789 |
+
if len(c[2]) > 4000:
|
| 790 |
+
c[2].clear()
|
| 791 |
+
c[2][(a, b)] = (tuple(ws), out)
|
| 792 |
+
return dict(out)
|
| 793 |
+
|
| 794 |
+
def _verify_raw(self, a, b):
|
| 795 |
+
m = self.L.model; H = self.e.Hbuf
|
| 796 |
+
sp = torch.tensor([[0, b - a]]) # local window: same mean / end state as over H[:n], O(span) not O(session)
|
| 797 |
+
rep = m.span_rep(H[a:b], sp)
|
| 798 |
+
out = {"ver": round(float(torch.sigmoid(m.ver(rep)[0, 0])), 3)}
|
| 799 |
+
if self.kbe is not None:
|
| 800 |
+
z = m.kb_logits(H[b - 1][None], self.kbe)[0].float()
|
| 801 |
+
p = torch.softmax(z, -1)
|
| 802 |
+
j = int(p[2:].argmax()) if p.numel() > 2 else -1
|
| 803 |
+
out["kb"] = {"p_new": round(float(p[0]), 3), "p_generic": round(float(p[1]), 3)}
|
| 804 |
+
if j >= 0:
|
| 805 |
+
out["kb"].update(entry=self.kb_names[j], p=round(float(p[2 + j]), 3), match=bool(p[2 + j] > max(p[0], p[1])))
|
| 806 |
+
return out
|
| 807 |
+
|
| 808 |
+
def _events(self, tier, i, P):
|
| 809 |
+
if tier == "T2":
|
| 810 |
+
return
|
| 811 |
+
w = self.words[i] if i < len(self.words) else {}
|
| 812 |
+
for q in ("ev_at", "ev_imm", "ev_rec"):
|
| 813 |
+
p = P.get(q)
|
| 814 |
+
if p is None or not len(p):
|
| 815 |
+
continue
|
| 816 |
+
on = np.nonzero((p >= self.th[q]) & self.valid[q])[0]
|
| 817 |
+
for c in on:
|
| 818 |
+
tag = self.cn[q][c]
|
| 819 |
+
if any((tier, tag, j) in self._ev_keys for j in range(i - 3, i + 1)):
|
| 820 |
+
continue
|
| 821 |
+
self._ev_keys.add((tier, tag, i))
|
| 822 |
+
self.events.append({"tier": tier, "tag": tag, "q": q, "i": int(i), "p": round(float(p[c]), 3),
|
| 823 |
+
"thr": round(float(self.th[q][c]), 3), "t": w.get("t1"), "wall": time.time(),
|
| 824 |
+
"word": w.get("w", "")})
|
| 825 |
+
|
| 826 |
+
def _alerts_utt(self, u):
|
| 827 |
+
for r in u["readouts"].get("safety", []):
|
| 828 |
+
k = (r["tag"], u["w0"])
|
| 829 |
+
if r["on"] and k not in self._al_keys:
|
| 830 |
+
self._al_keys.add(k)
|
| 831 |
+
self.alerts.append({"tag": r["tag"], "p": r["p"], "thr": r["thr"], "w0": u["w0"], "w1": u["w1"],
|
| 832 |
+
"text": u["text"][-160:], "wall": time.time(), "kind": "utterance"})
|
| 833 |
+
|
| 834 |
+
def span_alerts(self, spans, tier):
|
| 835 |
+
for s in spans:
|
| 836 |
+
if s["fam"] in ("pii", "profanity"):
|
| 837 |
+
k = (tier, s["tag"], s["a"])
|
| 838 |
+
if k not in self._al_keys:
|
| 839 |
+
self._al_keys.add(k)
|
| 840 |
+
self.alerts.append({"tag": s["tag"], "p": s["p"], "thr": s["thr"], "w0": s["a"], "w1": s["b"], "text": s["text"],
|
| 841 |
+
"wall": time.time(), "kind": f"span {tier}"})
|
| 842 |
+
|
| 843 |
+
def _top(self, q, P, prefix, k=3):
|
| 844 |
+
names = self.cn[q]
|
| 845 |
+
idx = [c for c, t in enumerate(names) if t.startswith(prefix) and self.valid[q][c]]
|
| 846 |
+
idx.sort(key=lambda c: -P[c])
|
| 847 |
+
return [{"tag": names[c], "p": round(float(P[c]), 3), "thr": round(float(self.th[q][c]), 3), "on": bool(P[c] >= self.th[q][c])} for c in idx[:k]]
|
| 848 |
+
|
| 849 |
+
@torch.inference_mode()
|
| 850 |
+
def readouts(self, j=None, final=False, tier="T0"):
|
| 851 |
+
"""utterance read-outs (pooled utt / speaker / item at word j) + frame states of the newest word of `tier`"""
|
| 852 |
+
if j is None:
|
| 853 |
+
j = self.pooled_now
|
| 854 |
+
if j is None or j not in self.e.pooled:
|
| 855 |
+
return {}
|
| 856 |
+
GI = self.L.GI
|
| 857 |
+
pl = self.e.pooled_logits(j)
|
| 858 |
+
U = GI.probs("utt", pl["utt"][None])[0].numpy(); S = GI.probs("speaker", pl["speaker"][None])[0].numpy()
|
| 859 |
+
I = GI.probs("item", pl["item"][None])[0].numpy()
|
| 860 |
+
sp = self.e.spk[j] if j < len(self.e.spk) else 0
|
| 861 |
+
ro = {"j": int(j), "final": final,
|
| 862 |
+
"intent": self._top("utt", U, "intent.", 3) + self._top("utt", U, "discourse.intent.", 3),
|
| 863 |
+
"act": self._top("utt", U, "discourse.act.", 3) + self._top("utt", U, "discourse.answer", 1) + self._top("utt", U, "discourse.backchannel", 1)
|
| 864 |
+
+ self._top("utt", U, "discourse.assistant.", 1),
|
| 865 |
+
"role": self._top("speaker", S, "speaker.role.", 3) if sp > 0 else [],
|
| 866 |
+
"role_note": None if sp > 0 else "speaker role needs diarization (no speaker id on words)",
|
| 867 |
+
"safety": [r for r in self._top("utt", U, "safety.", 20) if r["p"] >= 0.5 * r["thr"]][:6],
|
| 868 |
+
"item": self._top("item", I, "item.", 3)}
|
| 869 |
+
ro["intent"].sort(key=lambda r: -r["p"]); ro["intent"] = ro["intent"][:4]
|
| 870 |
+
fr = {}
|
| 871 |
+
jt = max([i for i in self.store[tier] if i <= j], default=None)
|
| 872 |
+
if jt is not None and "frame" in self.store[tier][jt]:
|
| 873 |
+
Fp = self.store[tier][jt]["frame"]
|
| 874 |
+
for g in STATE_GROUPS:
|
| 875 |
+
top = self._top("frame", Fp, g + ".", 3)
|
| 876 |
+
if top:
|
| 877 |
+
fr[g] = top
|
| 878 |
+
ro["state"] = fr
|
| 879 |
+
return ro
|
| 880 |
+
|
| 881 |
+
def stats(self):
|
| 882 |
+
L = self.lat[-500:]
|
| 883 |
+
if not L:
|
| 884 |
+
return {}
|
| 885 |
+
per_upd = np.array([x[0] for x in L]); per_w = np.array([x[0] / x[1] for x in L])
|
| 886 |
+
return {"upd_p50": round(float(np.percentile(per_upd, 50)), 2), "upd_p95": round(float(np.percentile(per_upd, 95)), 2),
|
| 887 |
+
"word_p50": round(float(np.percentile(per_w, 50)), 2), "word_p95": round(float(np.percentile(per_w, 95)), 2),
|
| 888 |
+
"n_updates": len(self.lat)}
|
arctag/_core/fast_arc.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast streaming engine for arc_text.ArcText (same maths as stream_arc.ArcStream / the batch forward; parity-tested).
|
| 2 |
+
|
| 3 |
+
What makes it fast (vs stream_arc.py, which calls the arc_audio modules per word):
|
| 4 |
+
(a) Mamba-3 in true recurrent mode for any T >= 1: ALL projections of a layer (in, x, B, C, theta, dt, lambda) are ONE fused
|
| 5 |
+
matmul over the T new words; the state recurrence is a T-step loop on (H, N, P) tensors; out_proj / MLP batched over T.
|
| 6 |
+
FiLM (domain + delay 0 constants) and the trunk input bias are folded per session; Trunk.step recomputed them every word.
|
| 7 |
+
(b) a revision of k words = ONE pass over the k-word chunk from the restored state (not k single steps).
|
| 8 |
+
(c) local attention: per layer a preallocated ring buffer of the window's K / V (32 words); only the new words' q / k / v.
|
| 9 |
+
(d) read-outs: Q_q and the tag keys are folded into one (d x C) matrix per read-out group (only the text tag columns), so the
|
| 10 |
+
T0 read of word t and the T1 read of word t-3 (both read h_t, differ by a delay-embedding constant) cost one matvec;
|
| 11 |
+
utt / speaker / item and T2 are computed only at an endpoint (batched); the pooled carries are updated every word.
|
| 12 |
+
(e) preallocated buffers, torch.inference_mode, no module-call overhead.
|
| 13 |
+
Snapshots for revision: per word the (state, carry, ring, slots) references of the last KEEP words.
|
| 14 |
+
"""
|
| 15 |
+
import argparse, bisect, json, math, os, sys, time
|
| 16 |
+
import numpy as np, torch, torch.nn.functional as F
|
| 17 |
+
|
| 18 |
+
HERE = os.path.dirname(os.path.abspath(__file__)); sys.path.insert(0, HERE)
|
| 19 |
+
import arc_text as AT # noqa: E402
|
| 20 |
+
from arc_text import WORD_Q, POOLED, D_MAX, P_MAX, NEG # noqa: E402
|
| 21 |
+
from arc_audio.mamba3.layer import rotate_pairs # noqa: E402
|
| 22 |
+
from tagtable import domain_index, asr_index # noqa: E402
|
| 23 |
+
from losses import GroupIndex # noqa: E402
|
| 24 |
+
import stream_arc as SA # noqa: E402
|
| 25 |
+
|
| 26 |
+
KEEP = 64
|
| 27 |
+
WQ = [q for q in WORD_Q if q != "span_beg"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class FastArc:
|
| 31 |
+
@torch.inference_mode()
|
| 32 |
+
def __init__(self, model, table, domain=None, asr=None, max_words=20000):
|
| 33 |
+
m = self.m = model.eval(); self.T = table; self.GI = GroupIndex(table)
|
| 34 |
+
self.asr_name = asr or "unknown"; self.asr = asr_index(asr); di = domain_index(domain)
|
| 35 |
+
self.dom = torch.tensor([di])
|
| 36 |
+
tr = m.trunk; d = m.d; self.d = d
|
| 37 |
+
self.tok = AT.ArcPieces(AT.BPE8K if getattr(model, "scratch", None) else AT.TOK_PATH); self.cache = {}
|
| 38 |
+
f32 = lambda t: t.detach().float().contiguous()
|
| 39 |
+
# ---- word input constants (no audio: hm = he = 0, have = 0)
|
| 40 |
+
w = m.word
|
| 41 |
+
self.tokE = w.tok_E; self.ppos = f32(w.ppos); self.W1 = f32(w.W1.weight).t().contiguous(); self.b1 = f32(w.W1.bias)
|
| 42 |
+
self.W2 = f32(w.W2.weight).t().contiguous(); self.ln = (f32(w.ln.weight), f32(w.ln.bias), w.ln.eps)
|
| 43 |
+
self.fl = [f32(e.weight) for e in w.fl]
|
| 44 |
+
src = 1 if self.asr_name == "arc_transcriber" else 0
|
| 45 |
+
self.c_in = f32(w.src.weight[src]) + ((f32(w.Wa.bias) + f32(w.hav.weight[0])) if hasattr(w, "Wa") else 0)
|
| 46 |
+
self.feat = {k: f32(e.weight) for k, e in m.feat.items()}
|
| 47 |
+
# ---- local attention layers
|
| 48 |
+
self.enc = []
|
| 49 |
+
for lay in w.enc:
|
| 50 |
+
self.enc.append(dict(n1=(f32(lay.n1.weight), f32(lay.n1.bias), lay.n1.eps), Wqkv=f32(lay.qkv.weight).t().contiguous(), bqkv=f32(lay.qkv.bias),
|
| 51 |
+
Wo=f32(lay.o.weight).t().contiguous(), bo=f32(lay.o.bias), n2=(f32(lay.n2.weight), f32(lay.n2.bias), lay.n2.eps),
|
| 52 |
+
F1=f32(lay.ff[0].weight).t().contiguous(), f1=f32(lay.ff[0].bias), F2=f32(lay.ff[3].weight).t().contiguous(),
|
| 53 |
+
f2=f32(lay.ff[3].bias), h=lay.h, win=lay.win))
|
| 54 |
+
self.enc_norm = (f32(w.enc_norm.weight), f32(w.enc_norm.bias), w.enc_norm.eps)
|
| 55 |
+
dw = w.ln.normalized_shape[0]; hE = self.enc[0]["h"]; dh = dw // hE; self.dh = dh; self.hE = hE; self.dw = dw
|
| 56 |
+
self.win = self.enc[0]["win"]
|
| 57 |
+
inv = 1.0 / (10000 ** (torch.arange(0, dh, 2, dtype=torch.float32) / dh)); self.rope_inv = inv
|
| 58 |
+
ang = torch.arange(max_words, dtype=torch.float32)[:, None] * inv # RoPE table (positions x dh/2)
|
| 59 |
+
self.rope_c, self.rope_s = torch.cos(ang)[:, None, :], torch.sin(ang)[:, None, :]
|
| 60 |
+
# ---- trunk: input projection, FiLM / domain constants folded
|
| 61 |
+
di_ = tr.delay_lut[0]
|
| 62 |
+
e_ = f32(tr.dom_emb.weight[di]) + f32(tr.delay_film.weight[di_])
|
| 63 |
+
self.in_norm = (f32(tr.in_norm.weight), f32(tr.in_norm.bias), tr.in_norm.eps)
|
| 64 |
+
self.Winp = f32(tr.inp.weight).t().contiguous(); self.binp = f32(tr.inp.bias) + f32(tr.dom_emb.weight[di]) + f32(tr.delay_emb.weight[di_])
|
| 65 |
+
self.layers = []
|
| 66 |
+
for i, (lay, mlp) in enumerate(zip(tr.layers, tr.mlps)):
|
| 67 |
+
gb = f32(tr.film[i](e_)); g, bb = 1 + gb[:d], gb[d:]
|
| 68 |
+
H, N, P = lay.H, lay.N, lay.P
|
| 69 |
+
Ws = [lay.in_proj, lay.x_proj, lay.B_proj, lay.C_proj, lay.theta_proj, lay.dt_proj, lay.lam_proj]
|
| 70 |
+
Wf = torch.cat([f32(l.weight) for l in Ws], 0).t().contiguous(); bf = torch.cat([f32(l.bias) for l in Ws])
|
| 71 |
+
sizes = [l.out_features for l in Ws]
|
| 72 |
+
self.layers.append(dict(g=g, b=bb, nw=f32(lay.norm.weight), neps=lay.norm.eps, Wf=Wf, bf=bf, sizes=sizes, H=H, N=N, P=P,
|
| 73 |
+
A=-torch.exp(f32(lay.A_log)), Bn=(f32(lay.B_norm.weight), lay.B_norm.eps), Cn=(f32(lay.C_norm.weight), lay.C_norm.eps),
|
| 74 |
+
Bb=f32(lay.B_bias)[..., 0], Cb=f32(lay.C_bias)[..., 0],
|
| 75 |
+
BCw=torch.stack([f32(lay.B_norm.weight), f32(lay.C_norm.weight)])[:, None, :],
|
| 76 |
+
BCb=torch.stack([f32(lay.B_bias)[..., 0], f32(lay.C_bias)[..., 0]]), Wout=f32(lay.out_proj.weight).t().contiguous(), bout=f32(lay.out_proj.bias),
|
| 77 |
+
mn=(f32(mlp[0].weight), mlp[0].eps), M1=f32(mlp[1].weight).t().contiguous(), m1=f32(mlp[1].bias),
|
| 78 |
+
M2=f32(mlp[4].weight).t().contiguous(), m2=f32(mlp[4].bias)))
|
| 79 |
+
self.adD = f32(tr.adapt_down[di]); self.adU = f32(tr.adapt_up[di]); self.on = (f32(tr.out_norm.weight), tr.out_norm.eps)
|
| 80 |
+
# ---- AT10M-v2: global attention layers (KV memory + chunk summaries, v2.GlobalAttn), delayed attention, span head, utt pool
|
| 81 |
+
import v2 as V2; self.V2 = V2
|
| 82 |
+
self.gat = {}
|
| 83 |
+
for k_, G in (getattr(m, "gat", None) or {}).items():
|
| 84 |
+
self.gat[int(k_)] = dict(nw=f32(G.norm.weight), neps=G.norm.eps, Wqkv=f32(G.qkv.weight).t().contiguous(), qn=(f32(G.qn.weight), G.qn.eps),
|
| 85 |
+
kn=(f32(G.kn.weight), G.kn.eps), Wo=f32(G.o.weight).t().contiguous(), h=G.h, dh=G.dh, mem=G.mem, ch=G.chunk, slots=G.slots,
|
| 86 |
+
K=torch.zeros(max_words, G.h, G.dh), Kr=torch.zeros(max_words, G.h, G.dh), V=torch.zeros(max_words, G.h, G.dh))
|
| 87 |
+
if getattr(G, "biased", False): # v2.1: ALiBi slopes, span-bearing key prior, tanh output gate
|
| 88 |
+
self.gat[int(k_)].update(biased=True, slope=f32(G.lslope).exp(), kb=f32(G.kb), kmW=f32(G.kmark.weight)[0], kmb=float(G.kmark.bias),
|
| 89 |
+
gate=float(torch.tanh(G.gate.detach().float())), M=torch.zeros(max_words))
|
| 90 |
+
self.gat[int(k_)]["Wo"] = self.gat[int(k_)]["Wo"] * self.gat[int(k_)]["gate"]
|
| 91 |
+
if self.gat:
|
| 92 |
+
g0 = next(iter(self.gat.values()))
|
| 93 |
+
self.grc, self.grs = V2.rope_tab(torch.arange(max_words), g0["dh"])
|
| 94 |
+
self.da = None
|
| 95 |
+
if hasattr(m, "dattn"):
|
| 96 |
+
A = m.dattn
|
| 97 |
+
self.da = dict(Wq=f32(A.q.weight).t().contiguous(), bq=f32(A.q.bias), Wk=f32(A.k.weight).t().contiguous(), bk=f32(A.k.bias),
|
| 98 |
+
Wv=f32(A.v.weight).t().contiguous(), bv=f32(A.v.bias), qd=f32(A.qd.weight), ko=f32(A.koff.weight), Wo=f32(A.o.weight).t().contiguous(), dk=A.dk)
|
| 99 |
+
self.sh = None
|
| 100 |
+
if hasattr(m, "span_head"):
|
| 101 |
+
S_ = m.span_head; ws_, bs_, self.sh_sl = [], [], {}; o_ = 0
|
| 102 |
+
for f in S_.fm:
|
| 103 |
+
for kind, mod in (("t", S_.typ[f.replace(".", "_")]), ("b", S_.bio[f.replace(".", "_")])):
|
| 104 |
+
ws_.append(f32(mod.weight).t()); bs_.append(f32(mod.bias)); self.sh_sl[(f, kind)] = (o_, o_ + mod.out_features); o_ += mod.out_features
|
| 105 |
+
self.sh = (torch.cat(ws_, 1).contiguous(), torch.cat(bs_)); self.sh_dq = f32(m.delay_q.weight) @ self.sh[0]
|
| 106 |
+
self.Zs = torch.zeros(max_words, o_)
|
| 107 |
+
self.ua = (f32(m.utt_att.weight)[0], float(m.utt_att.bias)) if hasattr(m, "utt_att") else None
|
| 108 |
+
# ---- read-outs: fold Q_q with the keys of its (text) columns; one matrix for the word-level read-outs
|
| 109 |
+
h_ = m.head; K = f32(m.keys()); valid = h_.valid[di]
|
| 110 |
+
cols, Ms, bs, self.wslices = [], [], [], {}
|
| 111 |
+
o = 0
|
| 112 |
+
for q in WQ:
|
| 113 |
+
c = h_.cols(q); Kc = K[c]
|
| 114 |
+
Ms.append(f32(h_.Q[q].weight).t() @ Kc.t()); bs.append(f32(h_.Q[q].bias) @ Kc.t() + f32(h_.b + h_.bq[q])[c])
|
| 115 |
+
msk = ~valid[c]; bs[-1] = bs[-1].masked_fill(msk, NEG)
|
| 116 |
+
self.wslices[q] = (o, o + len(c)); o += len(c)
|
| 117 |
+
self.Mw = torch.cat(Ms, 1).contiguous(); self.bw = torch.cat(bs)
|
| 118 |
+
self.dq = f32(m.delay_q.weight) @ self.Mw # delay-embedding constant per d (D_MAX+1, Cw)
|
| 119 |
+
self.Md = (f32(m.dmix.weight).t() @ self.Mw).contiguous() if hasattr(m, "dmix") else None # stage 2: word i's own state (d > 0)
|
| 120 |
+
self.dq[:, self.bw <= NEG / 2] = 0
|
| 121 |
+
cb = m.cols_span_beg; Kb = K[cb]
|
| 122 |
+
self.war = dict(r=(f32(w.war_r.weight).t().contiguous(), f32(w.war_r.bias)), k=f32(w.war_k.weight), ln=(f32(w.war_ln.weight), f32(w.war_ln.bias), w.war_ln.eps),
|
| 123 |
+
M=(f32(w.QB.weight).t() @ Kb.t()).contiguous(), b=(f32(w.QB.bias) @ Kb.t() + f32(m.bB_all)[cb]).masked_fill(~valid[cb], NEG))
|
| 124 |
+
self.pool = {}
|
| 125 |
+
P1 = torch.cat([f32(h_.pool_in[q][0].weight) for q in POOLED], 0).t().contiguous(); p1 = torch.cat([f32(h_.pool_in[q][0].bias) for q in POOLED])
|
| 126 |
+
self.P1, self.p1 = P1, p1
|
| 127 |
+
self.P2 = [(f32(h_.pool_in[q][2].weight).t().contiguous(), f32(h_.pool_in[q][2].bias)) for q in POOLED]
|
| 128 |
+
for q in POOLED:
|
| 129 |
+
c = h_.cols(q); Kc = K[c]
|
| 130 |
+
self.pool[q] = ((f32(h_.Q[q].weight).t() @ Kc.t()).contiguous(), (f32(h_.Q[q].bias) @ Kc.t() + f32(h_.b + h_.bq[q])[c]).masked_fill(~valid[c], NEG))
|
| 131 |
+
# ---- buffers
|
| 132 |
+
self.Hbuf = torch.zeros(max_words, d); self.Ubuf = torch.zeros(max_words, dw); self.Zw = torch.zeros(max_words, self.Mw.shape[1])
|
| 133 |
+
self.Zd = torch.zeros(max_words, self.Mw.shape[1]) if self.Md is not None else None
|
| 134 |
+
self.reset()
|
| 135 |
+
|
| 136 |
+
def reset(self):
|
| 137 |
+
self.mr = getattr(self.m, "memr", None) # v2.2 (a) MemRead: key cache over the words so far
|
| 138 |
+
if self.mr is not None:
|
| 139 |
+
dk = self.mr.dk; mw = self.Hbuf.shape[0]
|
| 140 |
+
self.Kc = torch.zeros(mw, dk); self.Vc = torch.zeros(mw, dk); self.Bc = torch.zeros(mw); self._mk = 0; self._mrc = {}
|
| 141 |
+
self.dqv = self.m.delay_q.weight.detach().float(); self.mr_g = math.tanh(float(self.mr.gate)); self.mr_gu = math.tanh(float(self.mr.gate_u))
|
| 142 |
+
self.n = 0; self.words = []; self.state = None; self.ustart = []
|
| 143 |
+
L = len(self.enc)
|
| 144 |
+
self.ring = [(torch.zeros(self.win, self.hE, self.dh), torch.zeros(self.win, self.hE, self.dh)) for _ in range(L)] # K, V by word % win
|
| 145 |
+
self.ring_ver = 0
|
| 146 |
+
self.carry = dict(utt=(torch.zeros(self.d, dtype=torch.float64), 0.0), item=(torch.zeros(self.d, dtype=torch.float64), 0.0),
|
| 147 |
+
speaker=(torch.zeros(10, self.d, dtype=torch.float64), torch.zeros(10, dtype=torch.float64)))
|
| 148 |
+
self.slots = {}; self.prev = None; self.next_ustart = True; self.spk = []
|
| 149 |
+
self.snap = {}; self.out = {"T0": {}, "T1": {}, "T2": {}}; self.pooled = {}
|
| 150 |
+
self.utt0 = 0
|
| 151 |
+
self.gsum = {li: {} for li in self.gat} # chunk summaries per global layer: chunk -> (k roped, v)
|
| 152 |
+
|
| 153 |
+
# ------------------------------------------------------------------ primitives
|
| 154 |
+
@staticmethod
|
| 155 |
+
def _mm(x, W):
|
| 156 |
+
return W.mm(x) if isinstance(W, QLin) else x @ W
|
| 157 |
+
|
| 158 |
+
@staticmethod
|
| 159 |
+
def _ln(x, p):
|
| 160 |
+
return F.layer_norm(x, (x.shape[-1],), p[0], p[1], p[2])
|
| 161 |
+
|
| 162 |
+
@staticmethod
|
| 163 |
+
def _rms(x, w, eps):
|
| 164 |
+
return F.rms_norm(x, (x.shape[-1],), w, eps)
|
| 165 |
+
|
| 166 |
+
def _rope(self, x, pos):
|
| 167 |
+
c, s = self.rope_c[pos], self.rope_s[pos]; x1, x2 = x[..., 0::2], x[..., 1::2]
|
| 168 |
+
return torch.stack([x1 * c - x2 * s, x1 * s + x2 * c], -1).flatten(-2)
|
| 169 |
+
|
| 170 |
+
def _embed(self, ws, fes, fls):
|
| 171 |
+
T = len(ws); Pm = torch.zeros(T, P_MAX, dtype=torch.long); npc = torch.zeros(T, dtype=torch.long)
|
| 172 |
+
for i, p in enumerate(ws):
|
| 173 |
+
Pm[i, :len(p)] = torch.tensor(p); npc[i] = len(p)
|
| 174 |
+
Et = self.tokE[Pm].float()
|
| 175 |
+
pm = (torch.arange(P_MAX)[None] < npc[:, None]).float()[..., None]
|
| 176 |
+
mean = ((Et + self.ppos) * pm).sum(1) / npc.clamp(min=1)[:, None].float()
|
| 177 |
+
last = Et[torch.arange(T), (npc - 1).clamp(min=0)]
|
| 178 |
+
e = self._ln(mean @ self.W1 + self.b1 + last @ self.W2, self.ln) + self.c_in
|
| 179 |
+
fl = torch.tensor(fls); fl[:, 2] = npc.clamp(0, P_MAX)
|
| 180 |
+
for i, tab in enumerate(self.fl):
|
| 181 |
+
e = e + tab[fl[:, i].clamp(0, tab.shape[0] - 1)]
|
| 182 |
+
for k, tab in self.feat.items():
|
| 183 |
+
xf = getattr(self, "xfeat", None) or {} # stage-2 session-constant inputs (spksrc; turn = 0 without Flux events)
|
| 184 |
+
e = e + tab[torch.tensor([f[k] if k in f else xf.get(k, 0) for f in fes]).clamp(0, tab.shape[0] - 1)]
|
| 185 |
+
return e
|
| 186 |
+
|
| 187 |
+
def _enc(self, x, j0):
|
| 188 |
+
"""local causal attention for words j0..j0+T-1 with ring-buffer K / V of the previous win-1 words"""
|
| 189 |
+
T = x.shape[0]; hE, dh, win = self.hE, self.dh, self.win
|
| 190 |
+
pos = torch.arange(j0, j0 + T)
|
| 191 |
+
for li, L in enumerate(self.enc):
|
| 192 |
+
q, k, v = (self._mm(self._ln(x, L["n1"]), L["Wqkv"]) + L["bqkv"]).view(T, 3, hE, dh).unbind(1)
|
| 193 |
+
q = self._rope(q, pos); k = self._rope(k, pos)
|
| 194 |
+
Kr, Vr = self.ring[li]
|
| 195 |
+
npast = min(j0, win - 1)
|
| 196 |
+
pidx = torch.arange(j0 - npast, j0) % win
|
| 197 |
+
Kall = torch.cat([Kr[pidx], k], 0); Vall = torch.cat([Vr[pidx], v], 0) # (npast+T, h, dh)
|
| 198 |
+
kp = torch.arange(j0 - npast, j0 + T)
|
| 199 |
+
msk = (kp[None] <= pos[:, None]) & (kp[None] > pos[:, None] - win)
|
| 200 |
+
a = F.scaled_dot_product_attention(q.transpose(0, 1)[None], Kall.transpose(0, 1)[None], Vall.transpose(0, 1)[None], attn_mask=msk[None, None])
|
| 201 |
+
a = a[0].transpose(0, 1).reshape(T, hE * dh)
|
| 202 |
+
# write the new K / V into a COPY of the ring (snapshots keep the old one)
|
| 203 |
+
Kn, Vn = Kr.clone(), Vr.clone(); Kn[pos % win] = k; Vn[pos % win] = v
|
| 204 |
+
self.ring[li] = (Kn, Vn)
|
| 205 |
+
x = x + self._mm(a, L["Wo"]) + L["bo"]
|
| 206 |
+
x = x + self._mm(F.gelu(self._mm(self._ln(x, L["n2"]), L["F1"]) + L["f1"]), L["F2"]) + L["f2"]
|
| 207 |
+
return self._ln(x, self.enc_norm)
|
| 208 |
+
|
| 209 |
+
def _trunk(self, u):
|
| 210 |
+
T = u.shape[0]
|
| 211 |
+
h = self._mm(self._ln(u, self.in_norm), self.Winp) + self.binp
|
| 212 |
+
state = self.state; new = []
|
| 213 |
+
for i, L in enumerate(self.layers):
|
| 214 |
+
h = h * L["g"] + L["b"]
|
| 215 |
+
nu = self._rms(h, L["nw"], L["neps"])
|
| 216 |
+
pr = self._mm(nu, L["Wf"]) + L["bf"]
|
| 217 |
+
zi, xx, Bp, Cp, th, dtp, lmp = torch.split(pr, L["sizes"], -1)
|
| 218 |
+
H, N, P = L["H"], L["N"], L["P"]
|
| 219 |
+
dt = F.softplus(dtp); alpha = torch.exp(dt * L["A"]); lam = torch.sigmoid(lmp)
|
| 220 |
+
gamma = lam * dt; beta = (1 - lam) * dt * alpha
|
| 221 |
+
BC = F.rms_norm(torch.stack([Bp.view(T, H, N), Cp.view(T, H, N)], 1), (N,), None, L["Bn"][1]) * L["BCw"] + L["BCb"]
|
| 222 |
+
Bm, Cm = BC[:, 0], BC[:, 1]
|
| 223 |
+
xv = xx.view(T, H, P); theta = th.view(T, H, N // 2) * dt[..., None]
|
| 224 |
+
bx = Bm[..., :, None] * xv[..., None, :] # (T,H,N,P)
|
| 225 |
+
st = None if state is None else state[i]
|
| 226 |
+
cth, sth = torch.cos(theta)[..., None], torch.sin(theta)[..., None] # (T,H,N/2,1)
|
| 227 |
+
ys = []
|
| 228 |
+
for t in range(T):
|
| 229 |
+
if st is None:
|
| 230 |
+
hs = gamma[t, :, None, None] * bx[t]
|
| 231 |
+
else:
|
| 232 |
+
pre = alpha[t, :, None, None] * st[0] + beta[t, :, None, None] * st[1] # (H,N,P); rotate the N pairs
|
| 233 |
+
c_, s_ = cth[t], sth[t]
|
| 234 |
+
a_, b_ = pre[:, 0::2], pre[:, 1::2]
|
| 235 |
+
hs = torch.stack([c_ * a_ - s_ * b_, s_ * a_ + c_ * b_], 2).reshape(H, N, P) + gamma[t, :, None, None] * bx[t]
|
| 236 |
+
st = (hs, bx[t])
|
| 237 |
+
ys.append((Cm[t][:, None, :] @ hs).reshape(-1))
|
| 238 |
+
new.append(st)
|
| 239 |
+
y = torch.stack(ys)
|
| 240 |
+
h = h + self._mm(y * F.silu(zi), L["Wout"]) + L["bout"]
|
| 241 |
+
h = h + self._mm(F.gelu(self._mm(self._rms(h, *L["mn"]), L["M1"]) + L["m1"]), L["M2"]) + L["m2"]
|
| 242 |
+
if i in self.gat:
|
| 243 |
+
h = h + self._gattn(i, h)
|
| 244 |
+
self.state = new
|
| 245 |
+
h = h + F.gelu(h @ self.adD) @ self.adU
|
| 246 |
+
return self._rms(h, *self.on)
|
| 247 |
+
|
| 248 |
+
def _gattn(self, li, h, qb=256):
|
| 249 |
+
"""v2.GlobalAttn for words j0..j0+T-1 (j0 = self.n): q / k / v of the new words into the position-indexed buffers, then each
|
| 250 |
+
query attends over its direct span (last mem words) + the newest `slots` complete chunk summaries older than that"""
|
| 251 |
+
G = self.gat[li]; T = h.shape[0]; j0 = self.n_run; H, dh, mem, ch = G["h"], G["dh"], G["mem"], G["ch"]
|
| 252 |
+
hn = self._rms(h, G["nw"], G["neps"])
|
| 253 |
+
q, k, v = self._mm(hn, G["Wqkv"]).view(T, 3, H, dh).unbind(1)
|
| 254 |
+
bi = G.get("biased", False)
|
| 255 |
+
q = self._rms(q, *G["qn"]); k = self._rms(k, *G["kn"])
|
| 256 |
+
pos = torch.arange(j0, j0 + T)
|
| 257 |
+
c, s = self.grc[pos], self.grs[pos]
|
| 258 |
+
qr = self.V2.rope(q, c, s); G["K"][pos] = k; G["Kr"][pos] = self.V2.rope(k, c, s); G["V"][pos] = v
|
| 259 |
+
if bi:
|
| 260 |
+
G["M"][pos] = torch.sigmoid(hn @ G["kmW"] + G["kmb"])
|
| 261 |
+
cache = self.gsum[li]
|
| 262 |
+
for cc in [cc for cc in cache if (cc + 1) * ch > j0]: # chunks touched by (re)written words
|
| 263 |
+
del cache[cc]
|
| 264 |
+
outs = []
|
| 265 |
+
for a in range(0, T, qb):
|
| 266 |
+
b = min(T, a + qb); i0, i1 = j0 + a, j0 + b # queries i0..i1-1
|
| 267 |
+
lo = max(0, i0 - mem + 1)
|
| 268 |
+
dm, sm = self.V2.summary_mask(i1 - i0, i1, i0, mem, ch, G["slots"])
|
| 269 |
+
dm = dm[:, lo:]
|
| 270 |
+
cs = sm.any(0).nonzero().flatten().tolist()
|
| 271 |
+
Ks, Vs = [G["Kr"][lo:i1]], [G["V"][lo:i1]]
|
| 272 |
+
if cs:
|
| 273 |
+
for cc in cs:
|
| 274 |
+
if cc not in cache:
|
| 275 |
+
kc = G["K"][cc * ch:(cc + 1) * ch].mean(0, keepdim=True); vc = G["V"][cc * ch:(cc + 1) * ch].mean(0, keepdim=True)
|
| 276 |
+
c1, s1 = self.V2.rope_tab(torch.tensor([cc * ch + ch // 2]), dh)
|
| 277 |
+
cache[cc] = (self.V2.rope(kc, c1, s1)[0], vc[0])
|
| 278 |
+
Ks.append(torch.stack([cache[cc][0] for cc in cs])); Vs.append(torch.stack([cache[cc][1] for cc in cs]))
|
| 279 |
+
dm = torch.cat([dm, sm[:, cs]], 1)
|
| 280 |
+
Kall = torch.cat(Ks, 0); Vall = torch.cat(Vs, 0)
|
| 281 |
+
msk = dm[None, None]
|
| 282 |
+
if bi: # -slope_h (i - j) + kb_h m_j (j = chunk centre / mean m)
|
| 283 |
+
jp = torch.cat([torch.arange(lo, i1)] + ([torch.tensor([cc * ch + ch // 2 for cc in cs])] if cs else [])).float()
|
| 284 |
+
mj = torch.cat([G["M"][lo:i1]] + ([torch.stack([G["M"][cc * ch:(cc + 1) * ch].mean() for cc in cs])] if cs else []))
|
| 285 |
+
ii = torch.arange(i0, i1).float()
|
| 286 |
+
bias = -G["slope"][:, None, None] * (ii[None, :, None] - jp[None, None, :]) + G["kb"][:, None, None] * mj[None, None, :]
|
| 287 |
+
msk = torch.where(dm[None], bias, torch.full_like(bias, float("-inf")))[None]
|
| 288 |
+
o = F.scaled_dot_product_attention(qr[a:b].transpose(0, 1)[None], Kall.transpose(0, 1)[None], Vall.transpose(0, 1)[None],
|
| 289 |
+
attn_mask=msk)
|
| 290 |
+
outs.append(o[0].transpose(0, 1).reshape(b - a, H * dh))
|
| 291 |
+
return self._mm(torch.cat(outs, 0), G["Wo"])
|
| 292 |
+
|
| 293 |
+
# ------------------------------------------------------------------ public API
|
| 294 |
+
def _snapshot(self):
|
| 295 |
+
return dict(state=self.state, carry=self.carry, ring=list(self.ring), slots=dict(self.slots), prev=self.prev, nu=self.next_ustart)
|
| 296 |
+
|
| 297 |
+
def _restore(self, k):
|
| 298 |
+
if k >= self.n:
|
| 299 |
+
return True
|
| 300 |
+
sn = self.snap.get(k)
|
| 301 |
+
if sn is None:
|
| 302 |
+
return False
|
| 303 |
+
self.state, self.carry, self.ring, self.slots, self.prev, self.next_ustart = sn["state"], sn["carry"], list(sn["ring"]), dict(sn["slots"]), sn["prev"], sn["nu"]
|
| 304 |
+
self.n = k; del self.words[k:], self.ustart[k:], self.spk[k:]
|
| 305 |
+
if self.mr is not None:
|
| 306 |
+
self._mk = min(self._mk, k); self._mrc = {c: v for c, v in self._mrc.items() if max(c) < k}
|
| 307 |
+
for t in self.out.values(): # outputs that READ a word >= k (out[tier][i] = read word index)
|
| 308 |
+
for j in [j for j, jr in t.items() if jr >= k]:
|
| 309 |
+
del t[j]
|
| 310 |
+
for j in [j for j in self.pooled if j >= k]:
|
| 311 |
+
del self.pooled[j]
|
| 312 |
+
for j in [j for j in self.snap if j > k]:
|
| 313 |
+
del self.snap[j]
|
| 314 |
+
return True
|
| 315 |
+
|
| 316 |
+
def pieces(self, w):
|
| 317 |
+
x = self.cache.get(w)
|
| 318 |
+
if x is None:
|
| 319 |
+
ids = self.tok.encode_word(w) or [0]
|
| 320 |
+
x = self.cache[w] = ids[:P_MAX - 1] + ids[-1:] if len(ids) > P_MAX else ids
|
| 321 |
+
return x
|
| 322 |
+
|
| 323 |
+
@torch.inference_mode()
|
| 324 |
+
def update(self, k, words):
|
| 325 |
+
"""words[k:] := words. Restores the nearest snapshot s <= k and runs words[s:k] + words as ONE chunk."""
|
| 326 |
+
k = min(k, self.n)
|
| 327 |
+
s0 = max([x for x in self.snap if x <= k], default=None)
|
| 328 |
+
if k < self.n and s0 is None:
|
| 329 |
+
ws = self.words[:k]; self.reset(); s0 = 0; rep_ = ws
|
| 330 |
+
else:
|
| 331 |
+
s0 = k if k >= self.n else s0
|
| 332 |
+
rep_ = self.words[s0:k]
|
| 333 |
+
self._restore(s0)
|
| 334 |
+
self._run(rep_ + [dict(w) for w in words])
|
| 335 |
+
return self.due
|
| 336 |
+
|
| 337 |
+
def _run(self, words):
|
| 338 |
+
"""ONE pass over the new words (append: 1 word; revision: the revised suffix as one chunk)"""
|
| 339 |
+
if not words:
|
| 340 |
+
return
|
| 341 |
+
j0 = self.n; T = len(words)
|
| 342 |
+
self.snap[j0] = self._snapshot() # state before the chunk (words inside: replay)
|
| 343 |
+
fes, fls, pcs, spks, uss = [], [], [], [], []
|
| 344 |
+
for i, w in enumerate(words):
|
| 345 |
+
fe, fl, spk = SA.ArcStream.feats(self, w)
|
| 346 |
+
fes.append(fe); fls.append(fl); pcs.append(self.pieces(w["w"])); spks.append(spk)
|
| 347 |
+
uss.append(self.next_ustart or (j0 + i) == 0); self.next_ustart = False
|
| 348 |
+
e = self._embed(pcs, fes, fls)
|
| 349 |
+
self.n_run = j0
|
| 350 |
+
u = self._enc(e, j0)
|
| 351 |
+
h = self._trunk(u)
|
| 352 |
+
self._after(j0, words, u, h, uss, spks)
|
| 353 |
+
|
| 354 |
+
def _after(self, j0, words, u, h, uss, spks):
|
| 355 |
+
T = len(words)
|
| 356 |
+
self.Hbuf[j0:j0 + T] = h; self.Ubuf[j0:j0 + T] = u
|
| 357 |
+
if self.mr is not None: # keys of rewritten words are recomputed lazily
|
| 358 |
+
self._mk = min(self._mk, j0); self._mrc = {c: v for c, v in self._mrc.items() if max(c) < j0}
|
| 359 |
+
self.Zw[j0:j0 + T] = h @ self.Mw + self.bw
|
| 360 |
+
if self.Zd is not None:
|
| 361 |
+
self.Zd[j0:j0 + T] = h @ self.Md
|
| 362 |
+
if self.sh is not None:
|
| 363 |
+
self.Zs[j0:j0 + T] = h @ self.sh[0] + self.sh[1]
|
| 364 |
+
z1 = F.gelu(self._mm(h, self.P1) + self.p1)
|
| 365 |
+
zs = [h + self._mm(z1[:, i * self.d:(i + 1) * self.d], W) + b for i, (W, b) in enumerate(self.P2)]
|
| 366 |
+
for i in range(T):
|
| 367 |
+
j = j0 + i
|
| 368 |
+
us = uss[i]; sp = spks[i]
|
| 369 |
+
cu, nu_ = self.carry["utt"]; ci, ni = self.carry["item"]; cs, ns = self.carry["speaker"]
|
| 370 |
+
w_ = 1.0 if self.ua is None else float(torch.exp((zs[0][i] @ self.ua[0] + self.ua[1]).clamp(-10, 10)))
|
| 371 |
+
cu = w_ * zs[0][i].double() + (0 if us else cu); nu_ = w_ + (0 if us else nu_)
|
| 372 |
+
ci = ci + zs[2][i].double(); ni = ni + 1
|
| 373 |
+
if sp > 0:
|
| 374 |
+
cs = cs.clone(); ns = ns.clone(); cs[sp - 1] += zs[1][i].double(); ns[sp - 1] += 1
|
| 375 |
+
self.carry = dict(utt=(cu, nu_), item=(ci, ni), speaker=(cs, ns))
|
| 376 |
+
self.pooled[j] = dict(utt=(cu / nu_).float(), item=(ci / ni).float(),
|
| 377 |
+
speaker=((cs[sp - 1] / ns[sp - 1]).float() if sp > 0 else torch.zeros(self.d)))
|
| 378 |
+
self.words.append(words[i]); self.ustart.append(us); self.spk.append(sp)
|
| 379 |
+
self.n = j0 + T
|
| 380 |
+
for x in [x for x in self.snap if x < self.n - KEEP]:
|
| 381 |
+
del self.snap[x]
|
| 382 |
+
# T0 for the new words; T1 for words whose read word is now available
|
| 383 |
+
new1 = [i for i in range(max(0, j0 - D_MAX), self.n) if i not in self.out["T1"]]
|
| 384 |
+
for j in range(j0, self.n):
|
| 385 |
+
self.out["T0"][j] = j
|
| 386 |
+
self._delayed(final=False)
|
| 387 |
+
self.due = {("T0", j): self.word_logits(j, "T0") for j in range(j0, self.n)} # read-outs due now
|
| 388 |
+
self.due.update({("T1", i): self.word_logits(i, "T1") for i in new1 if i in self.out["T1"]})
|
| 389 |
+
|
| 390 |
+
def _delayed(self, final, lo=None):
|
| 391 |
+
n = self.n
|
| 392 |
+
lo = max(0, n - 2 * D_MAX - 2) if lo is None else lo
|
| 393 |
+
starts = [i for i in range(max(0, min(lo, n - 3 * D_MAX)), n) if self.ustart[i]]
|
| 394 |
+
for tier, cap in (("T1", 3), ("T2", D_MAX)):
|
| 395 |
+
if tier == "T2" and not final:
|
| 396 |
+
continue # T2: computed at the endpoint only
|
| 397 |
+
for i in range(lo, n):
|
| 398 |
+
if i in self.out[tier]:
|
| 399 |
+
continue
|
| 400 |
+
s_next = next((s for s in starts if s > i), None)
|
| 401 |
+
end = (s_next - 1) if s_next is not None else (n - 1 if final else None)
|
| 402 |
+
jr = min(i + cap, end) if end is not None else (i + cap if i + cap <= n - 1 else None)
|
| 403 |
+
if jr is None:
|
| 404 |
+
continue
|
| 405 |
+
self.out[tier][i] = jr
|
| 406 |
+
|
| 407 |
+
@torch.inference_mode()
|
| 408 |
+
def endpoint(self):
|
| 409 |
+
"""utterance end: T1 / T2 of the closing utterance + its pooled read-outs (utt / speaker / item at the last word)"""
|
| 410 |
+
self.next_ustart = True
|
| 411 |
+
if not self.n:
|
| 412 |
+
return {}
|
| 413 |
+
s = max(i for i in range(self.n) if self.ustart[i])
|
| 414 |
+
n1 = [i for i in range(s, self.n) if i not in self.out["T1"]]; n2 = [i for i in range(s, self.n) if i not in self.out["T2"]]
|
| 415 |
+
self._delayed(final=True, lo=s)
|
| 416 |
+
due = {("T1", i): self.word_logits(i, "T1") for i in n1}
|
| 417 |
+
due.update({("T2", i): self.word_logits(i, "T2") for i in n2})
|
| 418 |
+
due[("pooled", self.n - 1)] = self.pooled_logits(self.n - 1)
|
| 419 |
+
return due
|
| 420 |
+
|
| 421 |
+
# ------------------------------------------------------------------ materialise logits (lazy; what an API call returns)
|
| 422 |
+
def word_logits(self, i, tier):
|
| 423 |
+
jr = self.out[tier][i]; d = jr - i
|
| 424 |
+
z = self.Zw[jr] + self.dq[min(d, D_MAX)]
|
| 425 |
+
if d > 0 and self.Zd is not None:
|
| 426 |
+
z = z + self.Zd[i]
|
| 427 |
+
ctx = None
|
| 428 |
+
if d > 0 and self.da is not None: # v2 A2: attention of word i over h_i..h_{i+d}
|
| 429 |
+
A = self.da; Hs = self.Hbuf[i:jr + 1]
|
| 430 |
+
kk = Hs @ A["Wk"] + A["bk"] + A["ko"][:d + 1]; vv = Hs @ A["Wv"] + A["bv"]
|
| 431 |
+
qv = self.Hbuf[i] @ A["Wq"] + A["bq"] + A["qd"][min(d, D_MAX)]
|
| 432 |
+
ctx = ((kk @ qv) / math.sqrt(A["dk"])).softmax(0) @ vv @ A["Wo"] # (d,) state increment
|
| 433 |
+
z = z + ctx @ self.Mw
|
| 434 |
+
ymr = amr = None
|
| 435 |
+
if self.mr is not None: # v2.2 (a): read over words < i, query = hd_i
|
| 436 |
+
ck_ = (i, jr) # cached per (word, read position); dropped on rewrites
|
| 437 |
+
if ck_ not in self._mrc:
|
| 438 |
+
self._mrc[ck_] = self._memread(i, self.Hbuf[jr] + self.dqv[min(d, D_MAX)] + (ctx if ctx is not None else 0))
|
| 439 |
+
ymr, amr = self._mrc[ck_]
|
| 440 |
+
z = z + ymr @ self.Mw
|
| 441 |
+
out = {q: z[a:b] for q, (a, b) in self.wslices.items()}
|
| 442 |
+
if self.sh is not None:
|
| 443 |
+
zs_ = self.Zs[jr] + self.sh_dq[min(d, D_MAX)] + (ctx @ self.sh[0] if ctx is not None else 0) + (ymr @ self.sh[0] if ymr is not None else 0)
|
| 444 |
+
out["span_head"] = {f: (zs_[self.sh_sl[(f, "t")][0]:self.sh_sl[(f, "t")][1]], zs_[self.sh_sl[(f, "b")][0]:self.sh_sl[(f, "b")][1]])
|
| 445 |
+
for f in self.V2.SPAN_FAMS}
|
| 446 |
+
k = min(d, 2); W = self.war
|
| 447 |
+
us = (self.Ubuf[i + k] @ W["r"][0] + W["r"][1]) if k else W["r"][1]
|
| 448 |
+
v = self._ln(self.Ubuf[i] + us + W["k"][k], W["ln"])
|
| 449 |
+
out["span_beg"] = v @ W["M"] + W["b"]
|
| 450 |
+
if amr is not None:
|
| 451 |
+
out["span_beg"] = out["span_beg"] + amr
|
| 452 |
+
return out
|
| 453 |
+
|
| 454 |
+
@torch.inference_mode()
|
| 455 |
+
def _memread(self, i, hd):
|
| 456 |
+
R = self.mr; n = self.n
|
| 457 |
+
if self._mk < n: # extend / refresh the key cache
|
| 458 |
+
hn = R.norm(self.Hbuf[self._mk:n]); self.Kc[self._mk:n] = R.kn(R.k(hn)); self.Vc[self._mk:n] = R.v(hn)
|
| 459 |
+
self.Bc[self._mk:n] = float(R.kb) * F.logsigmoid(R.kmark(hn)[:, 0]); self._mk = n
|
| 460 |
+
q = R.qn(R.q(R.norm(hd)))
|
| 461 |
+
sc = torch.cat([torch.zeros(1), self.Kc[:i] @ q / math.sqrt(R.dk) + self.Bc[:i]])
|
| 462 |
+
p = sc.softmax(0)[1:] # slot 0 = null key (zero value)
|
| 463 |
+
a = p @ self.Vc[:i]
|
| 464 |
+
return self.mr_g * R.o(a), self.mr_gu * R.ou(a) # state increment, span_beg logit increment
|
| 465 |
+
|
| 466 |
+
def pooled_logits(self, j):
|
| 467 |
+
return {q: self.pooled[j][q] @ self.pool[q][0] + self.pool[q][1] for q in POOLED}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ---------------------------------------------------------------------- parity + bench
|
| 471 |
+
def parity(sample, ckpt=None, init=None, n_words=150, scratch=None):
|
| 472 |
+
from data import Windows, Cfg, collate
|
| 473 |
+
m, T = SA.load(ckpt, init, scratch)
|
| 474 |
+
s = json.loads(open(sample).readline())
|
| 475 |
+
cfg = Cfg(); cfg.mlm = False; cfg.max_sub = 32
|
| 476 |
+
ex = Windows(T, AT.ArcPieces(AT.BPE8K if m.scratch else AT.TOK_PATH), cfg, train=False).example(s, 0, n_words, tier="T0"); b = collate([ex])
|
| 477 |
+
with torch.inference_mode():
|
| 478 |
+
ref = m(b, tiers=("T0", "T1", "T2"))["tiers"]
|
| 479 |
+
st = FastArc(m, T, domain=s.get("domain"), asr=s.get("asr"))
|
| 480 |
+
ust = set(np.nonzero(ex["ustart"])[0].tolist())
|
| 481 |
+
rv = next(i for i in range(n_words - 20, 10, -1) if not any(j in ust for j in range(i - 4, i + 1)))
|
| 482 |
+
for i, w in enumerate(s["words"][:n_words]):
|
| 483 |
+
if i in ust and i > 0:
|
| 484 |
+
st.endpoint()
|
| 485 |
+
st.update(i, [w])
|
| 486 |
+
if i == rv:
|
| 487 |
+
st.update(i - 4, [{"w": "xx"}, {"w": "yy"}, {"w": "zz"}])
|
| 488 |
+
st.update(i - 4, s["words"][i - 4: i + 1])
|
| 489 |
+
st.endpoint()
|
| 490 |
+
res = {}
|
| 491 |
+
for tier in ("T0", "T1", "T2"):
|
| 492 |
+
for q in WORD_Q:
|
| 493 |
+
R = ref[tier][q][0]
|
| 494 |
+
if R.shape[-1] == 0:
|
| 495 |
+
continue
|
| 496 |
+
got = torch.stack([st.word_logits(i, tier)[q] for i in range(n_words)])
|
| 497 |
+
fin = R > NEG / 2
|
| 498 |
+
if fin.any():
|
| 499 |
+
res[f"{tier}:{q}"] = round(float((R - got).abs()[fin].max()), 6)
|
| 500 |
+
for q in POOLED:
|
| 501 |
+
R = ref["T0"][q][0]; got = torch.stack([st.pooled_logits(i)[q] for i in range(n_words)]); fin = R > NEG / 2
|
| 502 |
+
if fin.any():
|
| 503 |
+
res[q] = round(float((R - got).abs()[fin].max()), 6)
|
| 504 |
+
return res, max(res.values())
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def bench(sample, prefix=1000, n=40, threads=1, int8=False, init=None, engine="fast"):
|
| 508 |
+
torch.set_num_threads(threads)
|
| 509 |
+
m, T = SA.load(None, init)
|
| 510 |
+
if int8:
|
| 511 |
+
import torch.ao.quantization as Q
|
| 512 |
+
if engine == "fast":
|
| 513 |
+
m = m # fast engine: weights are pre-folded fp32 matrices; int8 = quantised matmuls (qint8 per-tensor) below
|
| 514 |
+
else:
|
| 515 |
+
for name in ("trunk", "word"):
|
| 516 |
+
setattr(m, name, Q.quantize_dynamic(getattr(m, name), {torch.nn.Linear}, dtype=torch.qint8))
|
| 517 |
+
words = []
|
| 518 |
+
for line in open(sample):
|
| 519 |
+
words += json.loads(line)["words"]
|
| 520 |
+
if len(words) >= prefix + 2 * n:
|
| 521 |
+
break
|
| 522 |
+
st = (FastArc if engine == "fast" else SA.ArcStream)(m, T, domain="court_appellate", asr="deepgram")
|
| 523 |
+
if int8 and engine == "fast":
|
| 524 |
+
quantize_fast(st)
|
| 525 |
+
t = time.time()
|
| 526 |
+
for i in range(0, prefix, 1):
|
| 527 |
+
st.update(i, [words[i]])
|
| 528 |
+
tp = time.time() - t
|
| 529 |
+
ta, tr = [], []
|
| 530 |
+
for i in range(prefix, prefix + n):
|
| 531 |
+
t = time.perf_counter(); st.update(i, [words[i]]); ta.append(time.perf_counter() - t)
|
| 532 |
+
for _ in range(n // 2):
|
| 533 |
+
k = len(st.words) - 3
|
| 534 |
+
t = time.perf_counter(); st.update(k, [dict(w, final=True) for w in st.words[k:]]); tr.append(time.perf_counter() - t)
|
| 535 |
+
q = lambda x: round(1000 * float(np.median(x)), 2)
|
| 536 |
+
return dict(engine=engine, threads=threads, int8=int8, append_p50_ms=q(ta), append_p90_ms=round(1000 * float(np.percentile(ta, 90)), 2),
|
| 537 |
+
revise3_p50_ms=q(tr), revise3_p90_ms=round(1000 * float(np.percentile(tr, 90)), 2), prefix_s=round(tp, 1))
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
class QLin:
|
| 541 |
+
"""int8 weight (per output channel) x fp32 activation via torch.ops.quantized dynamic linear"""
|
| 542 |
+
def __init__(self, W_t, b=None):
|
| 543 |
+
W = W_t.t().contiguous()
|
| 544 |
+
self.p = torch.ops.quantized.linear_prepack(torch.quantize_per_channel(W, W.abs().amax(1).clamp(min=1e-8) / 127,
|
| 545 |
+
torch.zeros(W.shape[0], dtype=torch.long), 0, torch.qint8), b)
|
| 546 |
+
|
| 547 |
+
def mm(self, x):
|
| 548 |
+
return torch.ops.quantized.linear_dynamic(x.contiguous(), self.p)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def quantize_fast(st):
|
| 552 |
+
"""int8 for the big matrices of the fast engine (trunk fused / out / MLP, input proj, encoder); read-out matrices stay fp32"""
|
| 553 |
+
for L in st.layers:
|
| 554 |
+
for k in ("Wf", "Wout", "M1", "M2"):
|
| 555 |
+
L[k] = QLin(L[k])
|
| 556 |
+
st.Winp = QLin(st.Winp)
|
| 557 |
+
for L in st.enc:
|
| 558 |
+
for k in ("Wqkv", "Wo", "F1", "F2"):
|
| 559 |
+
L[k] = QLin(L[k])
|
| 560 |
+
st.P1 = QLin(st.P1); st.P2 = [(QLin(W), b) for W, b in st.P2]
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
if __name__ == "__main__":
|
| 564 |
+
ap = argparse.ArgumentParser()
|
| 565 |
+
ap.add_argument("--sample", default=os.path.join(os.path.dirname(HERE), "cache/sample/real.jsonl"))
|
| 566 |
+
ap.add_argument("--ckpt"); ap.add_argument("--init", default="/media/karan/Disk1/sidescreen-audiodec/v14/edge/ckpt/PK8a/step24000")
|
| 567 |
+
ap.add_argument("--parity", action="store_true"); ap.add_argument("--bench", action="store_true"); ap.add_argument("--int8", action="store_true")
|
| 568 |
+
ap.add_argument("--threads", type=int, default=1); ap.add_argument("--engine", default="fast"); ap.add_argument("--prefix", type=int, default=1000)
|
| 569 |
+
a = ap.parse_args()
|
| 570 |
+
torch.set_num_threads(a.threads)
|
| 571 |
+
if a.parity:
|
| 572 |
+
r, mx = parity(a.sample, a.ckpt, a.init)
|
| 573 |
+
print("fast parity max|dlogit|", mx, json.dumps(r))
|
| 574 |
+
if a.bench:
|
| 575 |
+
print("bench", json.dumps(bench(a.sample, a.prefix, threads=a.threads, int8=a.int8, init=a.init, engine=a.engine)))
|
arctag/_core/kb.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc Tagger long-list KB ("live RAG"): typed entries, lexical + phonetic index, distractors, per-session entry extraction.
|
| 2 |
+
The trained part (span query -> pointer over a K-shortlist + null, entry-aware verification, KB spotting) is in arc_text.py;
|
| 3 |
+
this module is pure CPU / numpy (data workers, eval, inference-side index). DESIGN.md section 13.
|
| 4 |
+
|
| 5 |
+
Entry: {"id", "type" (TYPES), "text", "aliases": [...], "value": optional canonical value, "fields": optional dict}
|
| 6 |
+
"""
|
| 7 |
+
import math, re, zlib
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
TYPES = ("name", "number", "keyphrase", "term", "product", "legal_ref", "slot_value", "fact")
|
| 11 |
+
TYPE_ID = {t: i for i, t in enumerate(TYPES)}
|
| 12 |
+
NAME_TAGS = {"person", "team", "organization", "place", "event_name", "game_entity", "game_map"}
|
| 13 |
+
STOP = set("""a an the and or but of to in on at for from by with as is are was were be been being it its this that these those i you he she
|
| 14 |
+
we they me him her us them my your his our their not no so if then than there here what which who whom whose when where why how all any
|
| 15 |
+
each some such very just also do does did done have has had having will would can could should may might must shall up down out over
|
| 16 |
+
into onto about after before again more most other own same too only s t don now ok okay yeah yes uh um oh like well right know think
|
| 17 |
+
going get got go say said see let one two three""".split())
|
| 18 |
+
WEB_SLOT_SRC = ("web_sgd", "web_mwoz", "web_abcd", "web_tm1", "web_tm2", "web_tm3")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------------------------------------- normalisation / keys
|
| 22 |
+
def norm(t):
|
| 23 |
+
return " ".join(re.findall(r"[a-z0-9]+", str(t).lower()))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
_PH = [(r"ph", "f"), (r"gh(?=[^aeiou]|$)", ""), (r"kn|gn|pn|wr|ps", lambda m: m.group(0)[1]), (r"ck|q", "k"), (r"x", "ks"),
|
| 27 |
+
(r"c(?=[iey])", "s"), (r"c", "k"), (r"dg(?=[iey])", "j"), (r"g(?=[iey])", "j"), (r"sch", "sk"), (r"sh|ch|tch", "x"),
|
| 28 |
+
(r"th", "0"), (r"tion|sion", "xn"), (r"z", "s"), (r"v", "f"), (r"w(?=[^aeiou]|$)", ""), (r"h(?=[^aeiou]|$)", ""),
|
| 29 |
+
(r"b|p", "p"), (r"d|t", "t"), (r"m", "m"), (r"(.)\1+", r"\1")]
|
| 30 |
+
_DIG = {"0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine"}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
_PHC = [(re.compile(a), b) for a, b in _PH]
|
| 34 |
+
_NONA = re.compile(r"[^a-z]"); _VOW = re.compile(r"[aeiouy]")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@__import__("functools").lru_cache(maxsize=1 << 18)
|
| 38 |
+
def phon_word(w):
|
| 39 |
+
"""compact metaphone-style key of one word: first letter kept, vowels dropped after it, consonant classes merged"""
|
| 40 |
+
w = _NONA.sub("", w.lower())
|
| 41 |
+
if not w:
|
| 42 |
+
return ""
|
| 43 |
+
k = w
|
| 44 |
+
for a, b in _PHC:
|
| 45 |
+
k = a.sub(b, k)
|
| 46 |
+
return (k[0] + _VOW.sub("", k[1:]))[:6] if k else w[:1]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def phon(t):
|
| 50 |
+
"""phonetic key of a phrase (digits spelled out, so '2' ~ 'two')"""
|
| 51 |
+
ws = []
|
| 52 |
+
for w in re.findall(r"[a-z]+|[0-9]", str(t).lower()):
|
| 53 |
+
ws.append(phon_word(_DIG.get(w, w)))
|
| 54 |
+
return " ".join(x for x in ws if x)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def ngrams(t, n=3):
|
| 58 |
+
s = f" {norm(t)} "
|
| 59 |
+
return [s[i:i + n] for i in range(max(1, len(s) - n + 1))]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
H = 1 << 18
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _h(g):
|
| 66 |
+
return zlib.crc32(g.encode()) & (H - 1)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@__import__("functools").lru_cache(maxsize=1 << 19)
|
| 70 |
+
def grams_of(t):
|
| 71 |
+
"""hashed char-3gram ids of a text (cached: pool entries are re-indexed in every sampled list)"""
|
| 72 |
+
return np.unique(np.fromiter((_h(x) for x in ngrams(t)), np.int64))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@__import__("functools").lru_cache(maxsize=1 << 19)
|
| 76 |
+
def phon_c(t):
|
| 77 |
+
return phon(t)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---------------------------------------------------------------------------------------------------------- lexical + phonetic index
|
| 81 |
+
class LexIndex:
|
| 82 |
+
"""char-3gram TF-IDF (hashed, sparse) over entry texts + aliases, plus phonetic-key buckets (exact phrase key and per-word keys).
|
| 83 |
+
query(texts, K) -> (n, K) entry indices (-1 pad) ranked by max(cosine, phonetic bonus)."""
|
| 84 |
+
def __init__(self, entries):
|
| 85 |
+
import scipy.sparse as sp
|
| 86 |
+
self.entries = entries; rows, cols, vals, owner = [], [], [], []
|
| 87 |
+
surf = []
|
| 88 |
+
for i, e in enumerate(entries):
|
| 89 |
+
for t in [e["text"]] + list(e.get("aliases") or []):
|
| 90 |
+
surf.append(t); owner.append(i)
|
| 91 |
+
self.owner = np.array(owner, np.int64)
|
| 92 |
+
grams = [grams_of(t) for t in surf]
|
| 93 |
+
df = np.bincount(np.concatenate(grams), minlength=H).astype(np.float32) if grams else np.zeros(H, np.float32)
|
| 94 |
+
self.idf = np.log((1 + len(surf)) / (1 + df)).astype(np.float32) + 1
|
| 95 |
+
lens = np.array([len(g) for g in grams], np.int64); cols = np.concatenate(grams) if grams else np.zeros(0, np.int64)
|
| 96 |
+
rows = np.repeat(np.arange(len(surf)), lens); w = self.idf[cols]
|
| 97 |
+
nrm = np.sqrt(np.bincount(rows, w ** 2, minlength=len(surf))); w = w / np.maximum(nrm[rows], 1e-6)
|
| 98 |
+
self.M = sp.csr_matrix((w, (rows, cols)), shape=(len(surf), H), dtype=np.float32)
|
| 99 |
+
self.MT = self.M.T.tocsr()
|
| 100 |
+
self.pk = {}
|
| 101 |
+
for r, t in enumerate(surf):
|
| 102 |
+
self.pk.setdefault(phon_c(t), set()).add(int(owner[r]))
|
| 103 |
+
|
| 104 |
+
def query(self, texts, K=64):
|
| 105 |
+
import scipy.sparse as sp
|
| 106 |
+
rows, cols, vals = [], [], []
|
| 107 |
+
for r, t in enumerate(texts):
|
| 108 |
+
g = grams_of(t); w = self.idf[g]; w = w / max(1e-6, float(np.sqrt((w ** 2).sum())))
|
| 109 |
+
rows += [r] * len(g); cols += g.tolist(); vals += w.tolist()
|
| 110 |
+
Q = sp.csr_matrix((vals, (rows, cols)), shape=(len(texts), H), dtype=np.float32)
|
| 111 |
+
S = (Q @ self.MT).toarray() if len(texts) * self.M.shape[0] <= 4e7 else None
|
| 112 |
+
out = np.full((len(texts), K), -1, np.int64); sc = np.zeros((len(texts), K), np.float32)
|
| 113 |
+
for r, t in enumerate(texts):
|
| 114 |
+
s = S[r] if S is not None else (Q[r] @ self.MT).toarray()[0]
|
| 115 |
+
best = {}
|
| 116 |
+
nz = np.nonzero(s > 0.05)[0]
|
| 117 |
+
if len(nz) > 4 * K:
|
| 118 |
+
nz = nz[np.argpartition(-s[nz], 4 * K)[:4 * K]]
|
| 119 |
+
for j in nz:
|
| 120 |
+
o = int(self.owner[j]); best[o] = max(best.get(o, 0.0), float(s[j]))
|
| 121 |
+
for o in self.pk.get(phon_c(t), ()): # sound-alike: phonetic key equal
|
| 122 |
+
best[o] = max(best.get(o, 0.0), 0.5 + 0.5 * best.get(o, 0.0))
|
| 123 |
+
top = sorted(best.items(), key=lambda x: -x[1])[:K]
|
| 124 |
+
for k, (o, v) in enumerate(top):
|
| 125 |
+
out[r, k] = o; sc[r, k] = v
|
| 126 |
+
return out, sc
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------------------------------------- distractors
|
| 130 |
+
def asr_corrupt(t, rng):
|
| 131 |
+
"""an ASR-style variant: char substitution / deletion / merge-split / homophone-ish vowel change / word drop"""
|
| 132 |
+
ws = t.split()
|
| 133 |
+
if not ws:
|
| 134 |
+
return t
|
| 135 |
+
r = rng.random(); i = rng.randrange(len(ws)); w = ws[i]
|
| 136 |
+
if r < 0.25 and len(w) > 3:
|
| 137 |
+
j = rng.randrange(1, len(w)); w = w[:j] + rng.choice("aeioubdgkmnprst") + w[j + 1:]
|
| 138 |
+
elif r < 0.45 and len(w) > 3:
|
| 139 |
+
j = rng.randrange(1, len(w)); w = w[:j] + w[j + 1:]
|
| 140 |
+
elif r < 0.6 and len(ws) > 1:
|
| 141 |
+
ws = ws[:i] + ws[i + 1:]; return " ".join(ws)
|
| 142 |
+
elif r < 0.75 and len(w) > 5:
|
| 143 |
+
j = rng.randrange(2, len(w) - 2); w = w[:j] + " " + w[j:]
|
| 144 |
+
else:
|
| 145 |
+
w = re.sub(r"[aeiou]", lambda m: rng.choice("aeiou"), w, count=1)
|
| 146 |
+
ws[i] = w
|
| 147 |
+
return " ".join(ws)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def near_number(t, rng):
|
| 151 |
+
"""near-miss number: one digit changed, two digits swapped, or the scale changed"""
|
| 152 |
+
ds = [m.start() for m in re.finditer(r"\d", t)]
|
| 153 |
+
if not ds:
|
| 154 |
+
return asr_corrupt(t, rng)
|
| 155 |
+
r = rng.random(); s = list(t)
|
| 156 |
+
if r < 0.5:
|
| 157 |
+
j = rng.choice(ds); s[j] = rng.choice([d for d in "0123456789" if d != s[j]])
|
| 158 |
+
elif r < 0.8 and len(ds) > 1:
|
| 159 |
+
j = rng.randrange(len(ds) - 1); a, b = ds[j], ds[j + 1]; s[a], s[b] = s[b], s[a]
|
| 160 |
+
else:
|
| 161 |
+
return t + "0" if rng.random() < 0.5 else (t[:-1] if len(t) > 1 else t + "1")
|
| 162 |
+
return "".join(s)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def wrong_qualifier(t, rng):
|
| 166 |
+
"""'close but wrong' variant that keeps the head: qualifier swapped (Inc / Corp, north / south, first / second, v. reversed)"""
|
| 167 |
+
swaps = [("inc", "corp"), ("north", "south"), ("east", "west"), ("first", "second"), ("senior", "junior"), ("new", "old"),
|
| 168 |
+
("upper", "lower"), ("international", "national"), ("v.", "vs."), ("i", "ii")]
|
| 169 |
+
low = t.lower()
|
| 170 |
+
for a, b in swaps:
|
| 171 |
+
if re.search(rf"\b{re.escape(a)}\b", low):
|
| 172 |
+
return re.sub(rf"\b{re.escape(a)}\b", b, low, count=1)
|
| 173 |
+
if re.search(rf"\b{re.escape(b)}\b", low):
|
| 174 |
+
return re.sub(rf"\b{re.escape(b)}\b", a, low, count=1)
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def distractors(entry, pool_by_type, pool_by_phon, rng, n=8):
|
| 179 |
+
"""hard distractors for one entry: phonetic neighbours (pool), same-type entries (pool), ASR-corrupted / near-miss / wrong-qualifier
|
| 180 |
+
variants of its own text. Returns entries tagged with 'dkind'."""
|
| 181 |
+
out = []
|
| 182 |
+
t = entry["text"]; ty = entry["type"]
|
| 183 |
+
for o in list(pool_by_phon.get(phon(t), ()))[:3]:
|
| 184 |
+
if norm(o["text"]) != norm(t):
|
| 185 |
+
out.append(dict(o, dkind="phonetic"))
|
| 186 |
+
same = pool_by_type.get(ty) or []
|
| 187 |
+
for _ in range(min(3, len(same))):
|
| 188 |
+
o = same[rng.randrange(len(same))]
|
| 189 |
+
if norm(o["text"]) != norm(t):
|
| 190 |
+
out.append(dict(o, dkind="same_type"))
|
| 191 |
+
v = near_number(t, rng) if ty == "number" else asr_corrupt(t, rng)
|
| 192 |
+
if norm(v) and norm(v) != norm(t):
|
| 193 |
+
out.append({"text": v, "type": ty, "aliases": [], "dkind": "near_miss" if ty == "number" else "asr_variant"})
|
| 194 |
+
q = wrong_qualifier(t, rng)
|
| 195 |
+
if q and norm(q) != norm(t):
|
| 196 |
+
out.append({"text": q, "type": ty, "aliases": [], "dkind": "wrong_qualifier"})
|
| 197 |
+
return out[:n]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def list_size(rng, lo=16, hi=10000):
|
| 201 |
+
return int(round(math.exp(rng.uniform(math.log(lo), math.log(hi)))))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ---------------------------------------------------------------------------------------------------------- per-session extraction
|
| 205 |
+
def _type_of(tag, src_file, ex):
|
| 206 |
+
if tag.startswith("entity.number.") or tag == "entity.numeral":
|
| 207 |
+
return "number"
|
| 208 |
+
if not tag.startswith("entity.mention."):
|
| 209 |
+
return None
|
| 210 |
+
k = tag.split(".")[2]
|
| 211 |
+
if any(src_file.startswith(w) for w in WEB_SLOT_SRC) and (ex or {}).get("src") == "dataset":
|
| 212 |
+
return "slot_value"
|
| 213 |
+
return {"work_product": "product", "legal_ref": "legal_ref", "term": "term"}.get(k, "name" if k in NAME_TAGS else None)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
LEGAL_RE = re.compile(r"\b(\d+\s+U\.?\s?S\.?\s?C\.?|§+\s*\d+|[A-Z][a-z]+\s+v\.?\s+[A-Z][a-z]+|Section\s+\d+[a-z]?|Rule\s+\d+)", re.I)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def keyphrases(words, ent_mask, idf, k=24, nmax=4):
|
| 220 |
+
"""salient non-entity n-grams (1-4 words, no stop word at either end, >= 1 letter word of >= 3 chars), scored by
|
| 221 |
+
session tf x idf (idf over a sample of sessions; unseen = high); multi-word preferred; non-overlapping with entity spans"""
|
| 222 |
+
low = [norm(w) for w in words]; cand = {}
|
| 223 |
+
for n in range(1, nmax + 1):
|
| 224 |
+
for i in range(len(low) - n + 1):
|
| 225 |
+
g = low[i:i + n]
|
| 226 |
+
if any(ent_mask[i:i + n]) or not all(g) or g[0] in STOP or g[-1] in STOP or not any(len(x) >= 3 and x.isalpha() for x in g):
|
| 227 |
+
continue
|
| 228 |
+
if n == 1 and (len(g[0]) < 5 or not g[0].isalpha()):
|
| 229 |
+
continue
|
| 230 |
+
key = " ".join(g); c = cand.setdefault(key, [0, i, n]); c[0] += 1
|
| 231 |
+
scored = []
|
| 232 |
+
for key, (tf, i, n) in cand.items():
|
| 233 |
+
if n == 1 and tf < 2:
|
| 234 |
+
continue
|
| 235 |
+
s = (1 + math.log(tf)) * idf.get(key, 8.0) * (1.0 + 0.5 * (n - 1))
|
| 236 |
+
scored.append((s, key, i, n))
|
| 237 |
+
scored.sort(reverse=True); out, used = [], np.zeros(len(low), bool)
|
| 238 |
+
for s, key, i, n in scored:
|
| 239 |
+
if used[i:i + n].any():
|
| 240 |
+
continue
|
| 241 |
+
used[i:i + n] = True; out.append((key, i, i + n))
|
| 242 |
+
if len(out) >= k:
|
| 243 |
+
break
|
| 244 |
+
return out
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def session_entries(s, src_file, idf, k_key=24):
|
| 248 |
+
"""true KB entries of a session from its own text + labels: [{"text", "type", "occ": [[a, b], ...], "value"?, "tag"?}]"""
|
| 249 |
+
W = [w["w"] for w in s.get("words") or []]; ent = {}; mask = np.zeros(len(W), bool)
|
| 250 |
+
for sp in (s.get("labels") or {}).get("span", []):
|
| 251 |
+
tag, a, b = sp[0], int(sp[1]), int(sp[2]); ex = sp[3] if len(sp) > 3 and isinstance(sp[3], dict) else {}
|
| 252 |
+
ty = _type_of(tag, src_file, ex)
|
| 253 |
+
if ty is None or b <= a:
|
| 254 |
+
continue
|
| 255 |
+
txt = " ".join(W[a:b]).strip(" .,;:!?\"'")
|
| 256 |
+
if len(norm(txt)) < 1 or (ty != "number" and len(norm(txt)) < 2):
|
| 257 |
+
continue
|
| 258 |
+
key = (ty, norm(txt)); e = ent.setdefault(key, {"text": txt, "type": ty, "occ": [], "tag": tag})
|
| 259 |
+
e["occ"].append([a, b]); mask[a:b] = True
|
| 260 |
+
if ex.get("canon") and ty == "number":
|
| 261 |
+
e["value"] = ex["canon"]
|
| 262 |
+
for e0 in (s.get("kb") or []): # the session's own KB list (names), as aliases-free entries
|
| 263 |
+
t = e0[0] if isinstance(e0, (list, tuple)) else e0
|
| 264 |
+
key = ("name", norm(t))
|
| 265 |
+
if norm(t) and key not in ent:
|
| 266 |
+
ent[key] = {"text": str(t), "type": "name", "occ": [], "tag": "kb"}
|
| 267 |
+
txt = " ".join(W)
|
| 268 |
+
for m in LEGAL_RE.finditer(txt):
|
| 269 |
+
key = ("legal_ref", norm(m.group(0)))
|
| 270 |
+
if key not in ent and norm(m.group(0)):
|
| 271 |
+
ent[key] = {"text": m.group(0), "type": "legal_ref", "occ": [], "tag": "regex"}
|
| 272 |
+
for key, a, b in keyphrases(W, mask, idf, k=k_key):
|
| 273 |
+
ent.setdefault(("keyphrase", key), {"text": " ".join(W[a:b]).strip(" .,;:!?\"'"), "type": "keyphrase", "occ": [[a, b]], "tag": "salience"})
|
| 274 |
+
locate_all(list(ent.values()), W)
|
| 275 |
+
return list(ent.values())
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def locate_all(E, W, nmax=8):
|
| 279 |
+
"""fill e["occ"] (if empty) with every occurrence (<= 20) of the entry's normalised text in the words: one n-gram -> positions map"""
|
| 280 |
+
need = [e for e in E if not e["occ"]]
|
| 281 |
+
if not need:
|
| 282 |
+
return
|
| 283 |
+
low = [norm(w) for w in W]; lens = {len(norm(e["text"]).split()) for e in need}; pos = {}
|
| 284 |
+
for n in lens:
|
| 285 |
+
if 1 <= n <= nmax:
|
| 286 |
+
for i in range(len(low) - n + 1):
|
| 287 |
+
pos.setdefault(" ".join(low[i:i + n]), []).append(i)
|
| 288 |
+
for e in need:
|
| 289 |
+
nt = norm(e["text"]); n = len(nt.split())
|
| 290 |
+
e["occ"] = [[i, i + n] for i in pos.get(nt, [])[:20]]
|
arctag/_core/kb_strfeat.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v2.2 (b) / (c) string features for the long-list KB (zero-init add-ons, arc_text.add_kb_str):
|
| 2 |
+
(b) char-n-gram mention key: hashed char 3-grams of the heard span text -> EmbeddingBag added to the KB query before normalisation;
|
| 3 |
+
(c) ver_e string features: explicit (span text, entry text) similarity features into the entry-aware verifier's hidden layer."""
|
| 4 |
+
import zlib, difflib, functools
|
| 5 |
+
import kb as KBM
|
| 6 |
+
NB = 4096 # hash buckets
|
| 7 |
+
NF = 8 # string features
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def grams(t):
|
| 11 |
+
t = f"#{KBM.norm(t)}#"
|
| 12 |
+
return [zlib.crc32(t[i:i + 3].encode()) % NB for i in range(max(1, len(t) - 2))]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def cng_batch(texts):
|
| 16 |
+
"""-> (ids list, offsets list) for nn.EmbeddingBag"""
|
| 17 |
+
ids, off = [], []
|
| 18 |
+
for t in texts:
|
| 19 |
+
off.append(len(ids)); ids += grams(t)
|
| 20 |
+
return ids, off
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _g3(t):
|
| 24 |
+
t = f"#{t}#"; return {t[i:i + 3] for i in range(max(1, len(t) - 2))}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@functools.lru_cache(maxsize=200000)
|
| 28 |
+
def sfeat(q, e):
|
| 29 |
+
"""(span text, entry text) -> NF floats in [0, 1]"""
|
| 30 |
+
qn, en = KBM.norm(q), KBM.norm(e)
|
| 31 |
+
if not qn or not en:
|
| 32 |
+
return [0.0] * NF
|
| 33 |
+
a, b = _g3(qn), _g3(en); qt, et = set(qn.split()), set(en.split())
|
| 34 |
+
qd, ed = "".join(c for c in qn if c.isdigit()), "".join(c for c in en if c.isdigit())
|
| 35 |
+
return [len(a & b) / max(1, len(a | b)), len(qt & et) / max(1, len(qt | et)), difflib.SequenceMatcher(None, qn, en).ratio(),
|
| 36 |
+
min(len(qn), len(en)) / max(len(qn), len(en)), float(qn == en), float(qn[:3] == en[:3]),
|
| 37 |
+
float(qd == ed) if (qd or ed) else 0.5, float(KBM.phon(qn) == KBM.phon(en))]
|
arctag/_core/losses.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Arc Tagger losses: masked BCE (independent tags), soft-CE with a null class over exclusive-group members (members whose target
|
| 2 |
+
is NaN = not labelled-for drop out of the softmax, as pk_safety.readout_loss_null(excl=...)), KD on teacher probabilities with the
|
| 3 |
+
same form, span verification / KB-match, MLM."""
|
| 4 |
+
import torch, torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
from tagtable import QUERIES
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GroupIndex:
|
| 10 |
+
"""per read-out: column positions of independent tags and of each exclusive group"""
|
| 11 |
+
def __init__(self, T, w_weak=0.3):
|
| 12 |
+
self.indep, self.groups, self.colw = {}, {}, {}
|
| 13 |
+
for q in QUERIES:
|
| 14 |
+
cols = T.cols[q]
|
| 15 |
+
self.indep[q] = torch.tensor([k for k, i in enumerate(cols) if i not in T.group_of], dtype=torch.long)
|
| 16 |
+
g = {}
|
| 17 |
+
for k, i in enumerate(cols):
|
| 18 |
+
if i in T.group_of:
|
| 19 |
+
g.setdefault(T.group_of[i], []).append(k)
|
| 20 |
+
self.groups[q] = {n: torch.tensor(v, dtype=torch.long) for n, v in g.items()}
|
| 21 |
+
self.colw[q] = torch.tensor([w_weak if i in T.weak else 1.0 for i in cols])
|
| 22 |
+
self.posw = None # stage 2: {q: (C,) false-negative weight} used at T0 only (set_fn_weights)
|
| 23 |
+
self.ls = {} # stage 2: {(q, group): label smoothing} for exclusive-group soft-CE (e.g. discourse.intent .1)
|
| 24 |
+
self.clsw = {} # m4 (11-06): {(q, group): (M,) per-member positive-row multiplier} (e.g. ritual_procedural 2.0);
|
| 25 |
+
# normaliser unchanged (like posw). Empty = identical to before.
|
| 26 |
+
self.typw = {} # m5/m6 (11-06): {q: (C,) positive-target multiplier, ALL tiers, independent columns} (--type_pos_w,
|
| 27 |
+
# e.g. entity.mention.organization 1.5); normaliser unchanged. Empty = identical to before.
|
| 28 |
+
self._dev = {}
|
| 29 |
+
|
| 30 |
+
def set_fn_weights(self, T, weights, queries=("span_beg", "span_in", "span_end")):
|
| 31 |
+
"""asymmetric T0 loss: positive-target multiplier per tag (redact.FN_W_T0) on the span read-outs; 1 elsewhere"""
|
| 32 |
+
self.posw = {q: torch.tensor([float(weights.get(T.ids[i], 1.0)) if q in queries else 1.0 for i in T.cols[q]]) for q in QUERIES}
|
| 33 |
+
return self
|
| 34 |
+
|
| 35 |
+
def set_type_pos_w(self, T, spec, queries=("span_beg", "span_in", "span_end")):
|
| 36 |
+
"""spec 'tag=w,...' -> positive-target multiplier on the span read-outs of independent (non-exclusive) columns, every tier"""
|
| 37 |
+
want = {}
|
| 38 |
+
for x in (spec or "").split(","):
|
| 39 |
+
if x.strip():
|
| 40 |
+
k, v = x.rsplit("=", 1); want[k.strip()] = float(v)
|
| 41 |
+
hit = set()
|
| 42 |
+
for q in queries:
|
| 43 |
+
ind = set(self.indep[q].tolist()); v = []
|
| 44 |
+
for k, i in enumerate(T.cols[q]):
|
| 45 |
+
x = want.get(T.ids[i], 1.0) if k in ind else 1.0; v.append(x)
|
| 46 |
+
if x != 1.0: hit.add(T.ids[i])
|
| 47 |
+
if any(x != 1.0 for x in v):
|
| 48 |
+
self.typw[q] = torch.tensor(v)
|
| 49 |
+
miss = set(want) - hit
|
| 50 |
+
if miss:
|
| 51 |
+
raise ValueError(f"--type_pos_w: not independent span columns: {sorted(miss)}")
|
| 52 |
+
return self
|
| 53 |
+
|
| 54 |
+
def set_class_w(self, T, spec):
|
| 55 |
+
"""spec 'tag=w,tag=w' -> per-member multipliers of exclusive groups (tags outside exclusive groups are rejected)"""
|
| 56 |
+
want = {}
|
| 57 |
+
for x in (spec or "").split(","):
|
| 58 |
+
if x.strip():
|
| 59 |
+
k, v = x.rsplit("=", 1); want[k.strip()] = float(v)
|
| 60 |
+
hit = set()
|
| 61 |
+
for q in QUERIES:
|
| 62 |
+
cols = T.cols[q]
|
| 63 |
+
for n, m in self.groups[q].items():
|
| 64 |
+
v = [want.get(T.ids[cols[k]], 1.0) for k in m.tolist()]
|
| 65 |
+
if any(x != 1.0 for x in v):
|
| 66 |
+
self.clsw[(q, n)] = torch.tensor(v); hit.update(T.ids[cols[k]] for k in m.tolist() if T.ids[cols[k]] in want)
|
| 67 |
+
miss = set(want) - hit
|
| 68 |
+
if miss:
|
| 69 |
+
raise ValueError(f"--class_w: not members of an exclusive group: {sorted(miss)}")
|
| 70 |
+
return self
|
| 71 |
+
|
| 72 |
+
def on(self, dev):
|
| 73 |
+
"""index tensors resident on dev (cached: no per-call host->device copies)"""
|
| 74 |
+
dev = str(dev)
|
| 75 |
+
if dev not in self._dev:
|
| 76 |
+
self._dev[dev] = ({q: v.to(dev) for q, v in self.indep.items()},
|
| 77 |
+
{q: {n: m.to(dev) for n, m in g.items()} for q, g in self.groups.items()},
|
| 78 |
+
{q: v.to(dev) for q, v in self.colw.items()},
|
| 79 |
+
{q: {n: float(self.colw[q][m][0]) for n, m in g.items()} for q, g in self.groups.items()})
|
| 80 |
+
return self._dev[dev]
|
| 81 |
+
|
| 82 |
+
def probs(self, q, z):
|
| 83 |
+
"""logits (..., C) -> probabilities with the same layout (sigmoid / group softmax with null logit 0)"""
|
| 84 |
+
p = torch.sigmoid(z.float())
|
| 85 |
+
for n, m in self.groups[q].items():
|
| 86 |
+
zz = torch.cat([z[..., m].float(), torch.zeros_like(z[..., :1]).float()], -1)
|
| 87 |
+
p[..., m] = torch.softmax(zz, -1)[..., :-1]
|
| 88 |
+
return p
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def readout_loss(GI, q, z, tg, wq, wvalid, posw=None, cw=None):
|
| 92 |
+
"""z, tg (B,W,C); wq (B,W) word weight; posw (C,) optional false-negative weight (positive-target multiplier, stage-2 T0);
|
| 93 |
+
returns (sum loss, sum weight). The normaliser stays the unweighted labelled weight, so posw raises the FN cost only."""
|
| 94 |
+
dev = z.device; z = z.float()
|
| 95 |
+
fin = torch.isfinite(tg); t = torch.nan_to_num(tg)
|
| 96 |
+
w_word = wq * wvalid
|
| 97 |
+
tot = z.new_zeros(()); den = z.new_zeros(())
|
| 98 |
+
ind = GI.indep[q].to(dev)
|
| 99 |
+
if len(ind):
|
| 100 |
+
zi, ti, fi = z[..., ind], t[..., ind], fin[..., ind]
|
| 101 |
+
w = fi * w_word[..., None] * GI.colw[q].to(dev)[ind]
|
| 102 |
+
if cw is not None: # v2 per-cell weights (flagged gap tags, toxicity negatives)
|
| 103 |
+
w = w * cw.to(dev)[..., ind]
|
| 104 |
+
lw = w if posw is None else w * (1 + (posw.to(dev)[ind] - 1) * ti)
|
| 105 |
+
if q in GI.typw:
|
| 106 |
+
lw = lw * (1 + (GI.typw[q].to(dev)[ind] - 1) * ti)
|
| 107 |
+
tot = tot + (F.binary_cross_entropy_with_logits(zi, ti, reduction="none") * lw).sum(); den = den + w.sum()
|
| 108 |
+
for n, m in GI.groups[q].items():
|
| 109 |
+
m = m.to(dev); fm = fin[..., m]
|
| 110 |
+
row = fm.any(-1) & (w_word > 0)
|
| 111 |
+
if not row.any():
|
| 112 |
+
continue
|
| 113 |
+
zm = z[..., m].masked_fill(~fm, -1e4)
|
| 114 |
+
zz = torch.cat([zm, torch.zeros_like(zm[..., :1])], -1)[row]
|
| 115 |
+
tm = t[..., m][row]
|
| 116 |
+
s = tm.sum(-1, keepdim=True)
|
| 117 |
+
tt = torch.cat([tm, (1 - s).clamp(min=0)], -1)
|
| 118 |
+
tt = tt / tt.sum(-1, keepdim=True).clamp(min=1e-6)
|
| 119 |
+
eps_ = GI.ls.get((q, n), 0.0)
|
| 120 |
+
if eps_: # smoothing over the labelled members + null
|
| 121 |
+
av = torch.cat([fm[row].float(), torch.ones_like(tt[:, :1])], -1)
|
| 122 |
+
tt = (1 - eps_) * tt + eps_ * av / av.sum(-1, keepdim=True)
|
| 123 |
+
cw_g = GI.colw[q].to(dev)[m][0]
|
| 124 |
+
ww = w_word[row] * cw_g
|
| 125 |
+
if cw is not None:
|
| 126 |
+
ww = ww * cw.to(dev)[..., m].min(-1).values[row]
|
| 127 |
+
lw = ww if posw is None else ww * (1 + ((posw.to(dev)[m] - 1) * tm).sum(-1))
|
| 128 |
+
if (q, n) in GI.clsw:
|
| 129 |
+
lw = lw * (1 + ((GI.clsw[(q, n)].to(dev) - 1) * tm).sum(-1))
|
| 130 |
+
tot = tot + (-(tt * torch.log_softmax(zz, -1)).sum(-1) * lw).sum(); den = den + ww.sum()
|
| 131 |
+
return tot, den
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
LAZY = False # train.py --lazy 1: no host syncs (no .any()/float()/boolean indexing); per-readout losses stay tensors
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def readout_loss_lazy(GI, q, z, tg, wq, wvalid, posw=None, cw=None):
|
| 138 |
+
"""= readout_loss with masked weights instead of row selection (same value, no device->host sync)"""
|
| 139 |
+
ind_, grp_, colw_, cw_ = GI.on(z.device); z = z.float()
|
| 140 |
+
fin = torch.isfinite(tg); t = torch.nan_to_num(tg)
|
| 141 |
+
w_word = wq * wvalid
|
| 142 |
+
tot = z.new_zeros(()); den = z.new_zeros(())
|
| 143 |
+
ind = ind_[q]
|
| 144 |
+
if len(ind):
|
| 145 |
+
zi, ti, fi = z[..., ind], t[..., ind], fin[..., ind]
|
| 146 |
+
w = fi * w_word[..., None] * colw_[q][ind]
|
| 147 |
+
if cw is not None:
|
| 148 |
+
w = w * cw[..., ind]
|
| 149 |
+
lw = w if posw is None else w * (1 + (posw.to(z.device)[ind] - 1) * ti)
|
| 150 |
+
if q in GI.typw:
|
| 151 |
+
lw = lw * (1 + (GI.typw[q].to(z.device)[ind] - 1) * ti)
|
| 152 |
+
tot = tot + (F.binary_cross_entropy_with_logits(zi, ti, reduction="none") * lw).sum(); den = den + w.sum()
|
| 153 |
+
for n, m in grp_[q].items():
|
| 154 |
+
fm = fin[..., m]
|
| 155 |
+
row = (fm.any(-1) & (w_word > 0)).float()
|
| 156 |
+
zm = z[..., m].masked_fill(~fm, -1e4)
|
| 157 |
+
zz = torch.cat([zm, torch.zeros_like(zm[..., :1])], -1)
|
| 158 |
+
tm = t[..., m]
|
| 159 |
+
s = tm.sum(-1, keepdim=True)
|
| 160 |
+
tt = torch.cat([tm, (1 - s).clamp(min=0)], -1)
|
| 161 |
+
tt = tt / tt.sum(-1, keepdim=True).clamp(min=1e-6)
|
| 162 |
+
eps_ = GI.ls.get((q, n), 0.0)
|
| 163 |
+
if eps_:
|
| 164 |
+
av = torch.cat([fm.float(), torch.ones_like(tt[..., :1])], -1)
|
| 165 |
+
tt = (1 - eps_) * tt + eps_ * av / av.sum(-1, keepdim=True).clamp(min=1.0)
|
| 166 |
+
ww = w_word * row * cw_[q][n]
|
| 167 |
+
if cw is not None:
|
| 168 |
+
ww = ww * cw[..., m].min(-1).values
|
| 169 |
+
lw = ww if posw is None else ww * (1 + ((posw.to(z.device)[m] - 1) * tm).sum(-1))
|
| 170 |
+
if (q, n) in GI.clsw:
|
| 171 |
+
lw = lw * (1 + ((GI.clsw[(q, n)].to(z.device) - 1) * tm).sum(-1))
|
| 172 |
+
tot = tot + (-(tt * torch.log_softmax(zz, -1)).sum(-1) * lw).sum(); den = den + ww.sum()
|
| 173 |
+
return tot, den
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def tag_loss(GI, logits, b, key="tg", fn_weighted=False):
|
| 177 |
+
"""mean over all labelled (word, column) units of every read-out (Arc: one pooled tag loss); fn_weighted: GI.posw (stage-2 T0)"""
|
| 178 |
+
z0 = next(iter(logits.values()))
|
| 179 |
+
tot, den, per = z0.new_zeros((), dtype=torch.float32), z0.new_zeros((), dtype=torch.float32), {}
|
| 180 |
+
for q, z in logits.items():
|
| 181 |
+
pw = GI.posw[q] if (fn_weighted and GI.posw is not None) else None
|
| 182 |
+
if LAZY:
|
| 183 |
+
l, d = readout_loss_lazy(GI, q, z, b[key][q], b["wq"][q], b["wvalid"].float(), pw, (b.get("tgw") or {}).get(q) if key == "tg" else None)
|
| 184 |
+
per[q] = (l / d.clamp(min=1e-6)).detach(); tot = tot + l; den = den + d
|
| 185 |
+
continue
|
| 186 |
+
l, d = readout_loss(GI, q, z, b[key][q].to(z.device), b["wq"][q].to(z.device), b["wvalid"].to(z.device).float(), pw,
|
| 187 |
+
(b.get("tgw") or {}).get(q) if key == "tg" else None)
|
| 188 |
+
if d > 0:
|
| 189 |
+
per[q] = float(l / d)
|
| 190 |
+
tot = tot + l; den = den + d
|
| 191 |
+
return tot / den.clamp(min=1.0), per
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def norm_loss(model, out, b):
|
| 195 |
+
"""span verification BCE + KB-match CE over the session KB (+ null)"""
|
| 196 |
+
dev = out["h"].device; lv, nv, lk, nk = 0.0, 0, 0.0, 0
|
| 197 |
+
for k, spans in enumerate(b["spans"]):
|
| 198 |
+
if not spans:
|
| 199 |
+
continue
|
| 200 |
+
sp = torch.tensor([[x[0], x[1]] for x in spans], device=dev)
|
| 201 |
+
rep = model.norm.span_rep(out["h"][k], sp)
|
| 202 |
+
kbe = None
|
| 203 |
+
kbt = torch.tensor([x[3] for x in spans], device=dev)
|
| 204 |
+
vw = torch.tensor([x[4] if len(x) > 4 else 1.0 for x in spans], device=dev)
|
| 205 |
+
if (kbt != -100).any() and b["kb_ids"][k]:
|
| 206 |
+
kbe = model.encode_kb([torch.tensor(x, device=dev) for x in b["kb_ids"][k]])
|
| 207 |
+
v, m = model.norm(rep, kbe)
|
| 208 |
+
vt = torch.tensor([x[2] for x in spans], device=dev, dtype=torch.float32)
|
| 209 |
+
lv = lv + (F.binary_cross_entropy_with_logits(v.float(), vt, reduction="none") * vw).sum(); nv += float(vw.sum())
|
| 210 |
+
if m is not None:
|
| 211 |
+
sel = kbt != -100
|
| 212 |
+
tgt = torch.where((kbt >= 0) & (kbt < m.shape[1] - 1), kbt, torch.full_like(kbt, m.shape[1] - 1))
|
| 213 |
+
lk = lk + F.cross_entropy(m.float()[sel], tgt[sel], reduction="sum"); nk += int(sel.sum())
|
| 214 |
+
return (lv / max(nv, 1)), (lk / max(nk, 1) if nk else 0.0), nv, nk
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def mlm_loss(out, b):
|
| 218 |
+
t = b["mlm"].to(out["mlm"].device)
|
| 219 |
+
if not (t != -100).any():
|
| 220 |
+
return 0.0
|
| 221 |
+
return F.cross_entropy(out["mlm"].float().reshape(-1, out["mlm"].shape[-1]), t.reshape(-1), ignore_index=-100)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
SF1_Q = ("ev_at", "ev_imm", "ev_rec", "span_beg", "span_in", "span_end")
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def soft_f1_loss(GI, logits, b, queries=SF1_Q, min_pos=1.0, eps=0.1):
|
| 228 |
+
"""stage 2 (--w_sf1): differentiable soft-F1 per tag column over the whole batch, 1 - F1 averaged over the columns with >= min_pos
|
| 229 |
+
labelled positives in the batch (no host sync). p = GI.probs (sigmoid / group softmax), masked where the target is NaN or the
|
| 230 |
+
word is padding / has zero read-out weight. soft TP = sum p*y, FP = sum p*(1-y), FN = sum (1-p)*y; eps smooths empty columns.
|
| 231 |
+
Complements BCE for rare, timing-sensitive events (score, substitution, speaker.change), where AUC is high but F1 is low."""
|
| 232 |
+
tot = None; n = None
|
| 233 |
+
for q in queries:
|
| 234 |
+
if q not in logits:
|
| 235 |
+
continue
|
| 236 |
+
z = logits[q]; tg = b["tg"][q].to(z.device)
|
| 237 |
+
if z.shape[-1] == 0:
|
| 238 |
+
continue
|
| 239 |
+
m = (torch.isfinite(tg) & ((b["wq"][q].to(z.device) * b["wvalid"].to(z.device).float()) > 0)[..., None]).float()
|
| 240 |
+
y = torch.nan_to_num(tg) * m
|
| 241 |
+
p = torch.sigmoid(z.float()) # = GI.probs without in-place writes (autograd-safe)
|
| 242 |
+
for _, gm in GI.groups[q].items():
|
| 243 |
+
gm = gm.to(z.device)
|
| 244 |
+
zz = torch.cat([z[..., gm].float(), torch.zeros_like(z[..., :1]).float()], -1)
|
| 245 |
+
p = p.index_copy(-1, gm, torch.softmax(zz, -1)[..., :-1])
|
| 246 |
+
p = p * m
|
| 247 |
+
tp = (p * y).sum((0, 1)); fp = (p * (1 - y) * m).sum((0, 1)); fn = ((1 - p) * y).sum((0, 1))
|
| 248 |
+
f1 = 2 * tp / (2 * tp + fp + fn + eps)
|
| 249 |
+
use = (y.sum((0, 1)) >= min_pos).float()
|
| 250 |
+
l = ((1 - f1) * use).sum(); c = use.sum()
|
| 251 |
+
tot = l if tot is None else tot + l; n = c if n is None else n + c
|
| 252 |
+
if tot is None:
|
| 253 |
+
return next(iter(logits.values())).sum() * 0.0
|
| 254 |
+
return tot / n.clamp(min=1.0)
|
arctag/_core/speaker_link.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage-2 text speaker linking (DESIGN.md 14.8): at every turn start, a pointer over the window's speaker memory (one slot per
|
| 2 |
+
speaker cluster seen so far, <= data.LINK_K, = the mean of that cluster's turn embeddings) plus a NEW-speaker slot answers "same
|
| 3 |
+
speaker as cluster c". Fusion with ASR diarization: the input speaker ids (gold, simulated-ASR or provider) enter the trunk through the
|
| 4 |
+
existing spk / chg features and the pointer through two learned scalars (same-ASR-id evidence per cluster, unseen-ASR-id evidence for
|
| 5 |
+
NEW). Per-cluster roles: a role read-out on the cluster memory (incl. the current turn), trained to the cluster's majority gold role.
|
| 6 |
+
|
| 7 |
+
Training is teacher-forced (memory built from the gold clusters of earlier turns) and fully vectorised: with Z the turn embeddings and
|
| 8 |
+
q the turn queries, score(j, k) = sum_{i<j, C_i=k} q_j . z_i / n_jk. Inference / eval (free-running) assigns turns one by one and
|
| 9 |
+
builds the memory from its own decisions (`assign`).
|
| 10 |
+
|
| 11 |
+
Losses: pointer CE (T0 / T1 / T2 queries = turn start, start+2, start+7 clamped to the turn end), supervised-contrastive InfoNCE on
|
| 12 |
+
the turn embeddings (same speaker = positive, every other turn of the batch = negative), cluster-role soft-CE.
|
| 13 |
+
"""
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch, torch.nn as nn, torch.nn.functional as F
|
| 16 |
+
|
| 17 |
+
TIER_OFF = {"T0": 0, "T1": 2, "T2": 7}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SpkLink(nn.Module):
|
| 21 |
+
def __init__(self, d, n_roles, dz=128):
|
| 22 |
+
super().__init__()
|
| 23 |
+
self.att = nn.Linear(d, 1); self.zp = nn.Linear(d, dz); self.qp = nn.Linear(d, dz)
|
| 24 |
+
self.tier = nn.Embedding(3, dz); nn.init.zeros_(self.tier.weight)
|
| 25 |
+
self.new_v = nn.Parameter(torch.randn(dz) * 0.02); self.b_new = nn.Parameter(torch.zeros(()))
|
| 26 |
+
self.scale = nn.Parameter(torch.tensor(np.log(10.0), dtype=torch.float32))
|
| 27 |
+
self.w_same = nn.Parameter(torch.zeros(())); self.w_newid = nn.Parameter(torch.zeros(()))
|
| 28 |
+
self.role = nn.Linear(dz, max(1, n_roles)); self.n_roles = n_roles
|
| 29 |
+
|
| 30 |
+
# -------------------------------------------------------------- turn embeddings and queries (whole batch at once)
|
| 31 |
+
def turn_emb(self, h, turns):
|
| 32 |
+
"""h (B,W,d); turns [(b, s, e)] -> Z (T, dz) unit vectors: attention pool of h over each turn's words"""
|
| 33 |
+
dev = h.device
|
| 34 |
+
bi = torch.cat([torch.full((e - s,), b, dtype=torch.long) for b, s, e in turns]).to(dev)
|
| 35 |
+
wi = torch.cat([torch.arange(s, e) for b, s, e in turns]).to(dev)
|
| 36 |
+
ti = torch.cat([torch.full((e - s,), t, dtype=torch.long) for t, (b, s, e) in enumerate(turns)]).to(dev)
|
| 37 |
+
x = h[bi, wi].float(); a = self.att(x)[:, 0]
|
| 38 |
+
T = len(turns)
|
| 39 |
+
mx = torch.full((T,), -1e30, device=dev).scatter_reduce(0, ti, a, "amax")
|
| 40 |
+
ea = torch.exp(a - mx[ti]); den = torch.zeros(T, device=dev).index_add(0, ti, ea)
|
| 41 |
+
pooled = torch.zeros(T, x.shape[1], device=dev).index_add(0, ti, x * (ea / den[ti])[:, None])
|
| 42 |
+
return F.normalize(self.zp(pooled), dim=-1)
|
| 43 |
+
|
| 44 |
+
def queries(self, h, turns, tier):
|
| 45 |
+
k = list(TIER_OFF).index(tier); off = TIER_OFF[tier]
|
| 46 |
+
bi = torch.tensor([b for b, s, e in turns], device=h.device)
|
| 47 |
+
pi = torch.tensor([min(s + off, e - 1) for b, s, e in turns], device=h.device)
|
| 48 |
+
return F.normalize(self.qp(h[bi, pi].float()) + self.tier.weight[k], dim=-1)
|
| 49 |
+
|
| 50 |
+
# -------------------------------------------------------------- pointer logits for one window (teacher forced)
|
| 51 |
+
def logits(self, q, Z, C, A):
|
| 52 |
+
"""q, Z (T,dz); C (T,) memory clusters (-1 = not in memory); A (T,) input ASR-id slot at each turn start (0 = none)
|
| 53 |
+
-> (T, 1+K) logits [NEW, cluster 0..K-1], unseen clusters at -inf; n (T,K) memory counts"""
|
| 54 |
+
T = q.shape[0]; K = int(C.max().item()) + 1 if (C >= 0).any() else 1
|
| 55 |
+
O = F.one_hot(C.clamp(min=0), K).float() * (C >= 0).float()[:, None] # (T,K)
|
| 56 |
+
low = torch.tril(torch.ones(T, T, device=q.device), -1) # i < j
|
| 57 |
+
n = low @ O # (T,K) earlier turns per cluster
|
| 58 |
+
S = (q @ Z.t()) * low
|
| 59 |
+
sc = (S @ O) / n.clamp(min=1)
|
| 60 |
+
same = ((A[:, None] == A[None, :]) & (A[:, None] > 0)).float() * low # earlier turns with the same ASR id
|
| 61 |
+
fs = (same @ O) / n.clamp(min=1)
|
| 62 |
+
newid = ((A > 0) & (same.sum(1) == 0)).float()
|
| 63 |
+
lc = self.scale.exp() * sc + self.w_same * fs
|
| 64 |
+
lc = lc.masked_fill(n == 0, -1e4)
|
| 65 |
+
ln = self.scale.exp() * (q @ self.new_v) + self.b_new + self.w_newid * newid
|
| 66 |
+
return torch.cat([ln[:, None], lc], 1), n
|
| 67 |
+
|
| 68 |
+
def role_logits(self, Z, C):
|
| 69 |
+
"""cluster memory INCLUDING the current turn -> role logits (T, n_roles)"""
|
| 70 |
+
T = Z.shape[0]
|
| 71 |
+
same = ((C[:, None] == C[None, :]) & (C[:, None] >= 0)).float() * torch.tril(torch.ones(T, T, device=Z.device))
|
| 72 |
+
m = (same @ Z) / same.sum(1, keepdim=True).clamp(min=1)
|
| 73 |
+
return self.role(m)
|
| 74 |
+
|
| 75 |
+
# -------------------------------------------------------------- free-running assignment (eval / inference)
|
| 76 |
+
@torch.no_grad()
|
| 77 |
+
def assign(self, q, Z, A, K_max=16):
|
| 78 |
+
"""turn-by-turn decisions, memory from the model's own clusters -> predicted cluster per turn (new ids in order)"""
|
| 79 |
+
T = q.shape[0]; pred = torch.full((T,), -1, dtype=torch.long, device=q.device); nk = 0
|
| 80 |
+
for j in range(T):
|
| 81 |
+
if j == 0:
|
| 82 |
+
pred[0] = 0; nk = 1; continue
|
| 83 |
+
lg, _ = self.logits(q[: j + 1], Z[: j + 1], torch.cat([pred[:j], pred.new_full((1,), -1)]), A[: j + 1])
|
| 84 |
+
lg = lg[j]
|
| 85 |
+
if nk >= K_max:
|
| 86 |
+
lg = lg.clone(); lg[0] = -1e9
|
| 87 |
+
c = int(lg.argmax())
|
| 88 |
+
if c == 0:
|
| 89 |
+
pred[j] = nk; nk += 1
|
| 90 |
+
else:
|
| 91 |
+
pred[j] = c - 1
|
| 92 |
+
return pred
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def batch_turns(b):
|
| 96 |
+
"""collated batch -> flat turn list [(window, s, e)], gold clusters C, role targets R, window index per turn, ASR slot A"""
|
| 97 |
+
turns, C, R, wi, A = [], [], [], [], []
|
| 98 |
+
spk = b["spk"].cpu() if torch.is_tensor(b["spk"]) else torch.as_tensor(b["spk"])
|
| 99 |
+
for k, L in enumerate(b.get("link") or []):
|
| 100 |
+
if not L:
|
| 101 |
+
continue
|
| 102 |
+
roles = L.get("role") or [-1] * len(L["turns"])
|
| 103 |
+
for (s, e, c), r in zip(L["turns"], roles):
|
| 104 |
+
turns.append((k, s, e)); C.append(c); R.append(r); wi.append(k); A.append(int(spk[k, s]))
|
| 105 |
+
return turns, torch.tensor(C, dtype=torch.long), torch.tensor(R, dtype=torch.long), torch.tensor(wi, dtype=torch.long), \
|
| 106 |
+
torch.tensor(A, dtype=torch.long)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def link_loss(model, h, b, tiers=("T0", "T1", "T2"), tier_w=None, tau=0.1, max_anchor=1024):
|
| 110 |
+
"""-> (pointer CE, InfoNCE, role CE, stats); zero tensors when the batch has no linkable windows"""
|
| 111 |
+
m = model.spk_link; z0 = h.sum() * 0.0
|
| 112 |
+
turns, C, R, wi, A = batch_turns(b)
|
| 113 |
+
if len(turns) < 2:
|
| 114 |
+
return z0, z0, z0, {}
|
| 115 |
+
dev = h.device; C, R, wi, A = C.to(dev), R.to(dev), wi.to(dev), A.to(dev)
|
| 116 |
+
Z = m.turn_emb(h, turns)
|
| 117 |
+
Q = {t: m.queries(h, turns, t) for t in tiers}
|
| 118 |
+
tw = tier_w or {"T0": 1.0, "T1": 0.5, "T2": 0.5}
|
| 119 |
+
lp = z0; npt = 0; acc = {}
|
| 120 |
+
lr_ = z0; nr = 0
|
| 121 |
+
for k in torch.unique(wi).tolist():
|
| 122 |
+
sel = (wi == k).nonzero()[:, 0]
|
| 123 |
+
if len(sel) < 2:
|
| 124 |
+
continue
|
| 125 |
+
Ck, Ak, Zk = C[sel], A[sel], Z[sel]
|
| 126 |
+
for t in tiers:
|
| 127 |
+
lg, n = m.logits(Q[t][sel], Zk, Ck, Ak)
|
| 128 |
+
seen = n.gather(1, Ck.clamp(min=0)[:, None])[:, 0] > 0
|
| 129 |
+
y = torch.where(seen, Ck + 1, torch.zeros_like(Ck))
|
| 130 |
+
ok = Ck >= 0
|
| 131 |
+
lp = lp + tw[t] * F.cross_entropy(lg[ok], y[ok], reduction="sum")
|
| 132 |
+
if t == "T1":
|
| 133 |
+
acc.setdefault("n", 0); acc["n"] += int(ok.sum()); acc.setdefault("c", 0)
|
| 134 |
+
acc["c"] += int((lg[ok].argmax(1) == y[ok]).sum())
|
| 135 |
+
npt += int((Ck >= 0).sum())
|
| 136 |
+
Rk = R[sel]
|
| 137 |
+
if m.n_roles and (Rk >= 0).any(): # cluster-majority role (whole window) per turn
|
| 138 |
+
rl = m.role_logits(Zk, Ck)
|
| 139 |
+
oh = F.one_hot(Rk.clamp(min=0), m.n_roles).float() * (Rk >= 0).float()[:, None]
|
| 140 |
+
same = ((Ck[:, None] == Ck[None, :]) & (Ck[:, None] >= 0)).float()
|
| 141 |
+
dist = same @ oh; has = dist.sum(1) > 0
|
| 142 |
+
if has.any():
|
| 143 |
+
dist = dist[has] / dist[has].sum(1, keepdim=True)
|
| 144 |
+
lr_ = lr_ + (-(dist * F.log_softmax(rl[has].float(), -1)).sum(-1)).sum(); nr += int(has.sum())
|
| 145 |
+
lp = lp / max(1, npt) / sum(tw[t] for t in tiers)
|
| 146 |
+
lr_ = lr_ / max(1, nr)
|
| 147 |
+
# supervised-contrastive InfoNCE over the batch's turns
|
| 148 |
+
ok = (C >= 0).nonzero()[:, 0]
|
| 149 |
+
if len(ok) > max_anchor:
|
| 150 |
+
ok = ok[torch.randperm(len(ok), device=dev)[:max_anchor]]
|
| 151 |
+
lab = wi[ok] * 1000 + C[ok]; Zs = Z[ok]
|
| 152 |
+
sim = (Zs @ Zs.t()) / tau
|
| 153 |
+
eye = torch.eye(len(ok), device=dev, dtype=torch.bool)
|
| 154 |
+
sim = sim.masked_fill(eye, -1e4)
|
| 155 |
+
pos = (lab[:, None] == lab[None, :]) & ~eye
|
| 156 |
+
hp = pos.any(1)
|
| 157 |
+
if hp.any():
|
| 158 |
+
lsm = F.log_softmax(sim, 1)
|
| 159 |
+
ln = -((lsm * pos.float()).sum(1)[hp] / pos.float().sum(1)[hp]).mean()
|
| 160 |
+
else:
|
| 161 |
+
ln = z0
|
| 162 |
+
st = {"link_acc_T1": acc.get("c", 0) / max(1, acc.get("n", 0)), "link_turns": npt}
|
| 163 |
+
return lp, ln, lr_, st
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ------------------------------------------------------------------ eval
|
| 167 |
+
def _heur(C):
|
| 168 |
+
"""teacher-forced heuristics per turn: two-back (2-party alternation) and most-frequent-other-than-previous"""
|
| 169 |
+
T = len(C); out = {"two_back": [], "most_freq": []}
|
| 170 |
+
for j in range(T):
|
| 171 |
+
seen = set(C[:j])
|
| 172 |
+
y = C[j] if C[j] in seen else -1
|
| 173 |
+
p2 = C[j - 2] if j >= 2 else -1
|
| 174 |
+
cnt = {}
|
| 175 |
+
for c in C[:j]:
|
| 176 |
+
cnt[c] = cnt.get(c, 0) + 1
|
| 177 |
+
prev = C[j - 1] if j else None
|
| 178 |
+
cand = sorted(((v, c) for c, v in cnt.items() if c != prev), reverse=True)
|
| 179 |
+
pm = cand[0][1] if cand else -1
|
| 180 |
+
out["two_back"].append(p2 == y); out["most_freq"].append(pm == y)
|
| 181 |
+
return out
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _asr_only(C, A):
|
| 185 |
+
"""teacher-forced 'ASR ids alone': a seen ASR id -> the gold cluster most associated with it so far; unseen / none -> NEW"""
|
| 186 |
+
hist = {}; out = []
|
| 187 |
+
for j, (c, a) in enumerate(zip(C, A)):
|
| 188 |
+
y = c if c in set(C[:j]) else -1
|
| 189 |
+
if a > 0 and a in hist:
|
| 190 |
+
p = max(hist[a].items(), key=lambda x: x[1])[0]
|
| 191 |
+
else:
|
| 192 |
+
p = -1
|
| 193 |
+
out.append(p == y)
|
| 194 |
+
if a > 0:
|
| 195 |
+
hist.setdefault(a, {}); hist[a][c] = hist[a].get(c, 0) + 1
|
| 196 |
+
return out
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def der_like(gold, pred, w):
|
| 200 |
+
"""word-weighted speaker error after the optimal one-to-one cluster mapping (Hungarian) + purity / coverage"""
|
| 201 |
+
from scipy.optimize import linear_sum_assignment
|
| 202 |
+
g = {c: i for i, c in enumerate(sorted(set(gold)))}; p = {c: i for i, c in enumerate(sorted(set(pred)))}
|
| 203 |
+
M = np.zeros((len(g), len(p)))
|
| 204 |
+
for a, b_, x in zip(gold, pred, w):
|
| 205 |
+
M[g[a], p[b_]] += x
|
| 206 |
+
r, c = linear_sum_assignment(-M)
|
| 207 |
+
tot = M.sum()
|
| 208 |
+
return dict(err=1 - M[r, c].sum() / max(tot, 1), purity=M.max(0).sum() / max(tot, 1), coverage=M.max(1).sum() / max(tot, 1))
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@torch.no_grad()
|
| 212 |
+
def eval_windows(model, h, b, exs, tiers=("T0", "T1", "T2")):
|
| 213 |
+
"""one record per linkable window of a collated eval batch: teacher-forced accuracy per tier, heuristics, ASR-id-alone,
|
| 214 |
+
free-running DER-like (T1 queries), role consistency"""
|
| 215 |
+
m = model.spk_link; recs = []
|
| 216 |
+
turns, C, R, wi, A = batch_turns(b)
|
| 217 |
+
if not turns:
|
| 218 |
+
return recs
|
| 219 |
+
dev = h.device
|
| 220 |
+
Z = m.turn_emb(h, turns); Q = {t: m.queries(h, turns, t) for t in tiers}
|
| 221 |
+
C, R, wi, A = C.to(dev), R.to(dev), wi.to(dev), A.to(dev)
|
| 222 |
+
for k in torch.unique(wi).tolist():
|
| 223 |
+
sel = (wi == k).nonzero()[:, 0]
|
| 224 |
+
if len(sel) < 2:
|
| 225 |
+
continue
|
| 226 |
+
Ck, Ak, Zk = C[sel], A[sel], Z[sel]
|
| 227 |
+
ok = (Ck >= 0).cpu().numpy()
|
| 228 |
+
rec = dict(dom=exs[k]["dom"], asr=exs[k]["asr"], sid=exs[k]["sid"], turns=int(ok.sum()))
|
| 229 |
+
for t in tiers:
|
| 230 |
+
lg, n = m.logits(Q[t][sel], Zk, Ck, Ak)
|
| 231 |
+
seen = n.gather(1, Ck.clamp(min=0)[:, None])[:, 0] > 0
|
| 232 |
+
y = torch.where(seen, Ck + 1, torch.zeros_like(Ck))
|
| 233 |
+
rec[f"acc_{t}"] = int(((lg.argmax(1) == y).cpu().numpy() & ok).sum())
|
| 234 |
+
Cl = Ck.cpu().tolist(); Al = Ak.cpu().tolist()
|
| 235 |
+
hz = _heur(Cl); rec["heur"] = {kk: int(np.sum(np.array(v) & ok)) for kk, v in hz.items()}
|
| 236 |
+
rec["asr_id"] = int(np.sum(np.array(_asr_only(Cl, Al)) & ok)) if any(a > 0 for a in Al) else None
|
| 237 |
+
pred = m.assign(Q["T1"][sel] if "T1" in Q else Q[tiers[0]][sel], Zk, Ak).cpu().tolist()
|
| 238 |
+
wts = [e - s for (_, s, e) in [turns[i] for i in sel.tolist()]]
|
| 239 |
+
gg = [(c, p_, w_) for c, p_, w_ in zip(Cl, pred, wts) if c >= 0]
|
| 240 |
+
rec["der"] = der_like([x[0] for x in gg], [x[1] for x in gg], [x[2] for x in gg]) if gg else None
|
| 241 |
+
if m.n_roles:
|
| 242 |
+
rp = m.role_logits(Zk, Ck).argmax(1).cpu().tolist(); Rl = R[sel].cpu().tolist()
|
| 243 |
+
cons = []; racc = []
|
| 244 |
+
for c in set(x for x in Cl if x >= 0):
|
| 245 |
+
idx = [i for i, x in enumerate(Cl) if x == c]
|
| 246 |
+
rr = [rp[i] for i in idx]; maj = max(set(rr), key=rr.count)
|
| 247 |
+
cons += [x == maj for x in rr]
|
| 248 |
+
gl = [Rl[i] for i in idx if Rl[i] >= 0]
|
| 249 |
+
if gl:
|
| 250 |
+
gm = max(set(gl), key=gl.count); racc += [rp[i] == gm for i in idx]
|
| 251 |
+
rec["role_cons"] = (int(sum(cons)), len(cons)); rec["role_acc"] = (int(sum(racc)), len(racc))
|
| 252 |
+
recs.append(rec)
|
| 253 |
+
return recs
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def report(recs, dom_name=lambda d: str(d), tier="T1"):
|
| 257 |
+
"""-> {"all" | "dom:<d>" | "asr:<a>": {turns, acc_T0/T1/T2, acc_text (= acc_<tier>), acc_heur (best heuristic), acc_asr_id,
|
| 258 |
+
der / purity / coverage (word-weighted), role_consistency, role_acc}}"""
|
| 259 |
+
groups = {}
|
| 260 |
+
for r in recs:
|
| 261 |
+
for g in ("all", f"dom:{dom_name(r['dom'])}", f"asr:{r['asr']}"):
|
| 262 |
+
groups.setdefault(g, []).append(r)
|
| 263 |
+
out = {}
|
| 264 |
+
for g, rs in groups.items():
|
| 265 |
+
n = sum(r["turns"] for r in rs)
|
| 266 |
+
if not n:
|
| 267 |
+
continue
|
| 268 |
+
x = {"turns": n, "windows": len(rs)}
|
| 269 |
+
for t in ("T0", "T1", "T2"):
|
| 270 |
+
if f"acc_{t}" in rs[0]:
|
| 271 |
+
x[f"acc_{t}"] = round(sum(r[f"acc_{t}"] for r in rs) / n, 4)
|
| 272 |
+
x["acc_text"] = x.get(f"acc_{tier}")
|
| 273 |
+
hk = rs[0]["heur"].keys()
|
| 274 |
+
x["acc_heur_each"] = {k: round(sum(r["heur"][k] for r in rs) / n, 4) for k in hk}
|
| 275 |
+
x["acc_heur"] = max(x["acc_heur_each"].values())
|
| 276 |
+
ra = [r for r in rs if r.get("asr_id") is not None]
|
| 277 |
+
x["acc_asr_id"] = round(sum(r["asr_id"] for r in ra) / max(1, sum(r["turns"] for r in ra)), 4) if ra else None
|
| 278 |
+
dd = [r for r in rs if r.get("der")]
|
| 279 |
+
if dd:
|
| 280 |
+
w = [r["turns"] for r in dd]
|
| 281 |
+
for k in ("err", "purity", "coverage"):
|
| 282 |
+
x["der_" + k] = round(float(np.average([r["der"][k] for r in dd], weights=w)), 4)
|
| 283 |
+
rc = [r["role_cons"] for r in rs if r.get("role_cons") and r["role_cons"][1]]
|
| 284 |
+
x["role_consistency"] = round(sum(a for a, _ in rc) / sum(b_ for _, b_ in rc), 4) if rc else None
|
| 285 |
+
ra_ = [r["role_acc"] for r in rs if r.get("role_acc") and r["role_acc"][1]]
|
| 286 |
+
x["role_acc"] = round(sum(a for a, _ in ra_) / sum(b_ for _, b_ in ra_), 4) if ra_ else None
|
| 287 |
+
out[g] = x
|
| 288 |
+
return out
|