toranb commited on
Commit
a64c547
·
1 Parent(s): 7d0b4f7

Initial release: TheoBERT Base — biblical-domain masked language model

Browse files

273M-parameter custom bidirectional encoder pretrained from scratch on
biblical and theological text, packaged for Hugging Face Hub.

Model
- Custom architecture: 12 layers, hidden 768, 8 heads, RoPE on Q/K,
RMSNorm on Q/K, ReLU² MLP, gated value embeddings on even layers,
learned residual interpolation between block output and initial
embedding; MLM head: Linear → GELU → RMSNorm → Linear
- Weights released as fp16 safetensors (546 MB)
- config.json declares torch_dtype: float32 so default loads upcast on
read; CPU inference is numerically safe by default. Pass
dtype=torch.float16 for GPU fp16 inference.
- Loads via AutoModel / AutoModelForMaskedLM with trust_remote_code=True
- Bundles the google-bert/bert-base-uncased fast tokenizer so
AutoTokenizer.from_pretrained(repo) and the Hub fill-mask widget work
out of the box

Training
- Stage 1: 24 epochs MLM pretraining, 256-token context, 270K biblical
and theological sequences (final train loss 1.0679, accuracy 76.42%)
- Stage 2: 25 epochs whole-word-masking continued pretraining at 18%
mask rate (final train loss 0.8958, accuracy 79.66%)
- Custom dual optimizer: AdamW for embeddings, MLM head, residual
lambdas, and value gates; Muon for linear layer weight matrices

Evaluation
- 94.7% pass rate (517/546) on domain-specific MLM benchmark covering
9 theological categories; difficulty-weighted 94.6%
- fp16 round-trip verified lossless against the fp32 source checkpoint
- Canonical-knowledge cases overlap with biblical text in the training
corpus; that category should be read as in-distribution recall rather
than held-out generalization
- Full methodology, schema, and per-category breakdown in EVAL.md

Repository contents
- modeling_theo_bert_base.py / configuration_theo_bert_base.py — HF
PreTrainedModel and PretrainedConfig implementations
- muon.py — Muon optimizer (retained for self-contained fine-tuning)
- scripts/mlm_eval_safetensors.py — reproduces the 546-case benchmark
from a HF repo directory (--repo-dir, defaults to current directory)
- model.safetensors, config.json, checkpoint_metadata.json, eval.json
- tokenizer.json, tokenizer_config.json (bundled bert-base-uncased)
- MODEL_CARD.md, README.md, EVAL.md, LICENSE (Apache 2.0)

.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv
EVAL.md ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Evaluation Suite: TheoBERT Base MLM Benchmark
2
+
3
+ A 546-case domain-specific evaluation suite measuring masked language modeling performance on biblical and theological text.
4
+
5
+ ## Overview
6
+
7
+ The evaluation tests whether TheoBERT Base has internalized domain-specific semantics — not just surface-level co-occurrence. It goes beyond a standard perplexity measurement by testing the model against carefully constructed test cases that probe theological precision, canonical recall, and doctrinal discrimination.
8
+
9
+ **Result:** 94.7% pass rate (517 / 546), difficulty-weighted score 94.6%.
10
+
11
+ ## Test Types
12
+
13
+ The suite uses three evaluation strategies, each targeting a different aspect of model competence:
14
+
15
+ ### 1. Doctrinal Association (221 cases, `target_in_top_k`)
16
+
17
+ > *Does the model know what belongs in the blank?*
18
+
19
+ A sentence from scripture or doctrinal writing with one `[MASK]` token. The correct answer(s) must appear in the model's top-k predictions.
20
+
21
+ **Pass condition:** At least one correct or acceptable-alternative token appears in the top-k (k=5) predictions.
22
+
23
+ **Example:**
24
+ ```
25
+ Input: "For as often as you eat this bread and drink the cup,
26
+ you proclaim the Lord's [MASK] until he comes."
27
+ Target: "death"
28
+ Pass: "death" is in top-5 predicted tokens → ✅
29
+ ```
30
+
31
+ These cases span 9 theological categories (bibliology, christology, ecclesiology, eschatology, hamartiology, pneumatology, soteriology, theology proper, spiritual warfare, kingdom theology, Romans road).
32
+
33
+ ### 2. Canonical Knowledge (138 cases, `target_in_top_k`)
34
+
35
+ > *Does the model know the exact wording of specific verses?*
36
+
37
+ Direct recall of well-known biblical passages. No theological reasoning required — this tests pure memorization of canonical text.
38
+
39
+ > **Train/eval overlap note.** The training corpus includes bible text, so canonical-knowledge cases are not held out from the training distribution. The 88.4% pass rate on this category should be interpreted as in-distribution recall, not as evidence of generalization to unseen text.
40
+
41
+ **Pass condition:** Same as doctrinal association (correct token in top-5).
42
+
43
+ **Example:**
44
+ ```
45
+ Input: "Love bears all things, [MASK] all things, hopes all things,
46
+ endures all things."
47
+ Target: "believes"
48
+ Pass: "believes" is in top-5 → ✅
49
+ ```
50
+
51
+ This is the hardest category for the model (88.4% pass rate), with most failures clustering around Old Testament proper nouns.
52
+
53
+ ### 3. Contrastive Theology (187 cases, `correct_beats_foil`)
54
+
55
+ > *Can the model tell the difference between theologically correct and incorrect completions?*
56
+
57
+ A sentence with one `[MASK]` token, a theologically correct target, and a deliberately wrong *foil* — a word that is semantically plausible in general English but theologically incorrect in context. The model must assign higher probability to the target than the foil.
58
+
59
+ **Pass condition:** P(target) > P(foil). The margin is additionally classified as high (>0.10), medium (>0.02), or low (≤0.02) confidence.
60
+
61
+ **Example:**
62
+ ```
63
+ Input: "Paul teaches that believers are justified freely by God's [MASK],
64
+ not by human merit or works of the law."
65
+ Target: "grace"
66
+ Foil: "law"
67
+ Pass: P("grace") > P("law") → ✅
68
+ ```
69
+
70
+ The foil "law" is lexically primed by the later phrase "works of the law," making this a hard case (the model must override surface-level co-occurrence with doctrinal understanding). Failure examples like "power", "will", "plan" — generic divine attributes that miss the specific grace/law antithesis — are tracked as `critical_failure` signals.
71
+
72
+ ## Categories
73
+
74
+ | Category | Cases | Pass Rate | Description |
75
+ |---|---|---|---|
76
+ | Pneumatology | 42 | 100% | Holy Spirit: person, work, gifts, indwelling |
77
+ | Soteriology | 109 | 98.2% | Salvation: justification, sanctification, atonement |
78
+ | Ecclesiology | 40 | 97.5% | Church: body of Christ, ordinances, unity |
79
+ | Hamartiology | 34 | 97.1% | Sin: nature, origin, consequences |
80
+ | Christology | 84 | 96.4% | Christ: person, natures, incarnation, atonement |
81
+ | Eschatology | 36 | 94.4% | Last things: return of Christ, judgment, resurrection |
82
+ | Theology proper | 46 | 91.3% | God: attributes, trinity, sovereignty |
83
+ | Canonical knowledge | 138 | 88.4% | Verse-level recall from specific biblical passages |
84
+ | Bibliology | 6 | — | Scripture: inspiration, authority, sufficiency |
85
+ | Romans road | 8 | — | Evangelistic verses from Romans |
86
+ | Spiritual warfare | 2 | — | Cosmic conflict, armor of God |
87
+ | Kingdom theology | 1 | — | Already/not-yet kingdom framework |
88
+
89
+ Categories with very few cases (bibliology, Romans road, spiritual warfare, kingdom theology) are present in the eval but reported in aggregate; their individual pass rates should be interpreted cautiously.
90
+
91
+ ## Difficulty Levels
92
+
93
+ Each test case is assigned a difficulty rating:
94
+
95
+ | Difficulty | Cases | Pass Rate | Criteria |
96
+ |---|---|---|---|
97
+ | Easy | 99 | 94.9% | High-frequency verse, single obvious completion |
98
+ | Medium | 275 | 94.9% | Requires domain context but has strong lexical cues |
99
+ | Hard | 172 | 94.2% | Multiple plausible completions, adversarial foil priming, rare vocabulary, or multi-token targets |
100
+
101
+ ### What Makes a Case "Hard"
102
+
103
+ - **Foil priming** — The foil word appears elsewhere in the same sentence, creating lexical pressure toward the wrong answer (e.g., "law" in a sentence that ends with "works of the law")
104
+ - **Multi-piece targets** — Words that tokenize into 2+ wordpieces require sequence-level probability scoring across mask positions
105
+ - **Rare proper nouns** — Old Testament names, places, and transliterated terms with low training frequency
106
+ - **Abstract theological distinctions** — Cases where the model must discriminate between near-synonyms with different doctrinal implications
107
+
108
+ ## Multi-Token and Multi-Piece Handling
109
+
110
+ When a target word tokenizes into multiple wordpieces (e.g., "Nebuchadnezzar" → 5 subword tokens), the eval automatically:
111
+
112
+ 1. Expands `[MASK]` into the required number of mask tokens
113
+ 2. Uses beam search over the mask positions to find the target sequence
114
+ 3. Matches either exact sequence equality or subsequence containment (for targets that span fewer positions than available masks)
115
+
116
+ This handles cases like:
117
+ - `"sabachthani"` (multi-piece from Aramaic transliteration)
118
+ - `"iniquity"` (3 wordpieces in bert-base-uncased)
119
+ - `"propitiation"` (4 wordpieces)
120
+
121
+ Failure examples are also tracked for multi-piece targets — if the model produces a generic failure sequence (e.g., "power", "will") instead of the theologically correct multi-piece target, it's flagged.
122
+
123
+ ## Scoring Protocol
124
+
125
+ ### target_in_top_k (357 cases)
126
+
127
+ ```
128
+ pass = correct_token_id ∈ top_k(token_ids)
129
+ mrr = 1 / (rank of first correct token), or 0 if not found
130
+ ```
131
+
132
+ ### correct_beats_foil (187 cases)
133
+
134
+ ```
135
+ pass = P(target) > P(foil)
136
+ margin = P(target) - P(foil)
137
+ confidence = high if margin > 0.10
138
+ | medium if margin > 0.02
139
+ | low otherwise
140
+ ```
141
+
142
+ ### all_top_k_in_target_set (2 cases)
143
+
144
+ ```
145
+ pass = (valid_tokens_in_top_k / k) ≥ 0.8
146
+ ```
147
+
148
+ Used when multiple targets are equally acceptable and the model should surface the theological cluster rather than a single token.
149
+
150
+ ## Error Classification
151
+
152
+ When a test case fails, the eval attempts to classify *why*:
153
+
154
+ | Error type | Meaning |
155
+ |---|---|
156
+ | `near_miss` | Correct token ranked at k+1 or k+2 — nearly passed |
157
+ | `generic_over_theological` | Top predictions are generic/universal words (e.g., "power", "love", "will") rather than theologically specific terms |
158
+ | `wrong_semantic_cluster` | Wrong token is still semantically related but theologically incorrect |
159
+ | `total_miss` | Correct token ranked below position 20 — model has essentially no signal |
160
+
161
+ ## Critical Failures
162
+
163
+ A **critical failure** is a test case where, regardless of pass/fail status, one of the explicitly listed `failure_examples` tokens appears in the top-3 predictions. This signals that the model is drifting toward generic religious language rather than precise theological vocabulary.
164
+
165
+ Critical failure rate: extracted from the full results JSON.
166
+
167
+ ## Running the Evaluation
168
+
169
+ Install the dependencies first. `uv pip install` is recommended for speed and resolver behavior, but plain `pip install` works too:
170
+
171
+ ```bash
172
+ uv pip install -r requirements.txt
173
+ # or: pip install -r requirements.txt
174
+ ```
175
+
176
+ ```bash
177
+ # Default: load model.safetensors from repo root, eval against eval.json
178
+ python scripts/mlm_eval_safetensors.py
179
+
180
+ # With GPU
181
+ python scripts/mlm_eval_safetensors.py --device cuda
182
+
183
+ # Compare against a previous run (proves fp16 round-trip fidelity)
184
+ python scripts/mlm_eval_safetensors.py --compare eval_results/d12_encoder_mlm_eval.json
185
+
186
+ # Custom paths
187
+ python scripts/mlm_eval_safetensors.py \
188
+ --repo-dir /path/to/repo \
189
+ --eval-path /path/to/eval.json \
190
+ --device cuda
191
+
192
+ # Adjust top-k and sampling
193
+ python scripts/mlm_eval_safetensors.py --k 10 --n-samples 5
194
+ ```
195
+
196
+ The script writes results to `eval_results/safetensors_mlm_eval.json` by default.
197
+
198
+ ### Verifying fp16 Fidelity
199
+
200
+ The `--compare` flag diffs the safetensors (fp16→fp32) results against a prior evaluation of the original fp32 `.pt` checkpoint. If every test case produces the same pass/fail outcome, the fp16 storage is proven lossless for this model — no quantization artifacts affect semantic predictions.
201
+
202
+ ## Test Case Schema
203
+
204
+ Each test case in `eval.json` has this structure:
205
+
206
+ ```json
207
+ {
208
+ "id": "DOC_001",
209
+ "type": "doctrinal_association",
210
+ "category": "soteriology",
211
+ "difficulty": "medium",
212
+ "input": "Paul teaches that the message of the [MASK] is foolishness...",
213
+ "targets": ["cross"],
214
+ "foils": [],
215
+ "acceptable_alternatives": [],
216
+ "failure_examples": ["church", "gospel", "law", "bible", "world"],
217
+ "pass_condition": "target_in_top_k",
218
+ "k": 5,
219
+ "reference": "1 Corinthians 1:18",
220
+ "reasoning": "The cross as the central message of the gospel...",
221
+ "surface_confounder": ""
222
+ }
223
+ ```
224
+
225
+ | Field | Description |
226
+ |---|---|
227
+ | `id` | Unique identifier within the suite |
228
+ | `type` | One of: `doctrinal_association`, `canonical_knowledge`, `contrastive_theology` |
229
+ | `category` | Theological category (see table above) |
230
+ | `difficulty` | `easy`, `medium`, or `hard` |
231
+ | `input` | The masked sentence. Must contain at least one `[MASK]` |
232
+ | `targets` | Correct completion(s) for the masked position(s) |
233
+ | `foils` | Deliberately incorrect but plausible completions (contrastive only) |
234
+ | `acceptable_alternatives` | Also-correct completions beyond the primary target |
235
+ | `failure_examples` | Tokens that would indicate the model failed to internalize the domain, even if the primary target is predicted |
236
+ | `pass_condition` | Scoring strategy: `target_in_top_k`, `correct_beats_foil`, or `all_top_k_in_target_set` |
237
+ | `k` | Number of top predictions to consider |
238
+ | `reference` | Source verse or doctrinal concept |
239
+ | `reasoning` | Human-readable explanation of what the case tests and why the foil is wrong (if applicable) |
240
+ | `surface_confounder` | Linguistic surface feature that could mislead a shallow model (if any) |
241
+
242
+ ## Design Philosophy
243
+
244
+ This eval was designed to probe **domain-specific MLM behavior**, not general linguistic fluency. A general-purpose BERT model may score well on standard MLM benchmarks while producing theologically incoherent completions on biblical text. The three test types target different aspects of that behavior:
245
+
246
+ 1. **Doctrinal association** checks whether the model has absorbed domain-specific co-occurrence patterns — the "language" of theology
247
+ 2. **Canonical knowledge** checks whether the model has memorized specific verses — the "data" of scripture
248
+ 3. **Contrastive theology** checks whether the model prefers doctrinally correct completions over plausible foils
249
+
250
+ The foil-based contrastive cases are the most discriminative: they test whether the model assigns higher probability to a doctrinally correct target than to a surface-level lexical confounder. Results on this suite should be read as evidence about behavior on cases of this shape, not as a general measure of theological understanding. The training corpus and eval suite were authored privately and have not been externally audited, so some train/eval distributional overlap (especially for canonical recall) is expected.
LICENSE ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 describing the origin of the Work and
141
+ 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 Support. While redistributing the Work or
166
+ Derivative Works thereof, You may choose to offer, and charge a
167
+ fee for, acceptance of support, warranty, indemnity, or other
168
+ liability obligations and/or rights consistent with this License.
169
+ However, in accepting such obligations, You may act only on Your
170
+ own behalf and on Your sole responsibility, not on behalf of any
171
+ other Contributor, and only if You agree to indemnify, defend,
172
+ and hold each Contributor harmless for any liability incurred by,
173
+ or claims asserted against, such Contributor by reason of your
174
+ accepting any such warranty or support.
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 include a copyright
185
+ notice and an attribution notice with copyright information.
186
+
187
+ Copyright 2026 Toran Billups
188
+
189
+ Licensed under the Apache License, Version 2.0 (the "License");
190
+ you may not use this file except in compliance with the License.
191
+ You may obtain a copy of the License at
192
+
193
+ http://www.apache.org/licenses/LICENSE-2.0
194
+
195
+ Unless required by applicable law or agreed to in writing, software
196
+ distributed under the License is distributed on an "AS IS" BASIS,
197
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
198
+ implied. See the License for the specific language governing permissions
199
+ and limitations under the License.
MODEL_CARD.md ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Card: TheoBERT Base
2
+
3
+ ## Model Details
4
+
5
+ - **Model name:** TheoBERT Base (`theo-bert-base`)
6
+ - **Model type:** Bidirectional encoder with masked language modeling head
7
+ - **Architecture:** 12-layer Transformer with RoPE, ReLU² MLP, gated value embeddings, residual interpolation
8
+ - **Parameters:** 273,051,864 (≈273M)
9
+ - **Vocabulary:** 30,522 tokens (inherited from `google-bert/bert-base-uncased` tokenizer)
10
+ - **Context length:** 256 tokens (rotary cache supports up to 2,560)
11
+ - **License:** Apache 2.0
12
+ - **Contact:** [Toran Billups](https://huggingface.co/toranb)
13
+
14
+ ## Uses
15
+
16
+ ### Direct Use
17
+
18
+ TheoBERT Base is a masked language model for biblical and theological text. It is designed for:
19
+
20
+ - **Fill-mask inference** — predicting masked tokens in biblical-domain sentences
21
+ - **Token-level scoring** — computing log-probabilities of candidate tokens in context
22
+ - **Encoder hidden states** — extracting contextual embeddings for downstream task heads
23
+
24
+ ### Downstream Use
25
+
26
+ This model is suitable as a base for fine-tuning on domain-specific tasks such as:
27
+
28
+ - Named entity recognition (biblical figures, places, events)
29
+ - Text classification (theological topic, genre, author attribution)
30
+ - Sequence labeling (part-of-speech tagging for biblical languages, when paired with appropriate tokenizers)
31
+ - Continued pretraining or domain adaptation
32
+
33
+ ### Out-of-Scope Use
34
+
35
+ - **Text generation** — This is an encoder, not a decoder. It cannot autoregressively generate text.
36
+ - **General-domain NLP** — Trained exclusively on biblical and theological text. Performance on news, social media, or scientific text is untested and likely degraded.
37
+ - **Languages other than English** — Training data is English-language biblical material.
38
+
39
+ ## Bias, Risks, and Limitations
40
+
41
+ ### Domain Specialization
42
+
43
+ The model is trained on a narrow domain (biblical and theological English text). Its representations are shaped by the theological traditions present in the training corpus. Users should be aware that:
44
+
45
+ - The model's predictions reflect the distributional patterns of the training data, not ground-truth theological positions.
46
+ - Different theological traditions use the same terms with different meanings. The model may favor the usage patterns most prevalent in its training data.
47
+ - Tokenization is inherited from `bert-base-uncased`, which was trained on general English. Multi-piece subword splits on theological terms (e.g., "propitiation" → "prop", "##iti", "##ation") may degrade performance on rare vocabulary.
48
+
49
+ ### Training Data
50
+
51
+ The training data is private and consists of:
52
+
53
+ - 270,000 sequences from bible text, Christian books, biblical commentaries, and synthetic data
54
+ - Corpus composition is not publicly auditable, which limits external bias analysis
55
+
56
+ ### Known Failure Modes
57
+
58
+ - **Old Testament proper-noun recall** — Names like Jeremiah, Jonah, Job, and Nebuchadnezzar are weakly recalled
59
+ - **Multi-piece subword reconstruction** — Long words spanning many wordpieces (e.g., "sabachthani", "iniquity") have higher error rates
60
+ - **Canonical knowledge** — Lowest per-category performance (88.4%) relative to doctrinal categories
61
+
62
+ ### Environmental Impact
63
+
64
+ Training was conducted over 49 epochs total (24 + 25) on a single GPU. Carbon footprint was not directly measured but is modest relative to large-scale LLM training. The released fp16 weights (546 MB) reduce download bandwidth and inference memory.
65
+
66
+ ## Training Details
67
+
68
+ ### Training Data
69
+
70
+ Private corpus of 270,000 sequences drawn from:
71
+
72
+ - Bible text (multiple English translations)
73
+ - Christian theological books and commentaries
74
+ - Synthetic data generated for domain coverage
75
+
76
+ No public dataset identifiers are available.
77
+
78
+ ### Training Procedure
79
+
80
+ **Stage 1 — MLM Pretraining (24 epochs)**
81
+
82
+ - Objective: Masked language modeling
83
+ - Sequence length: 256 tokens
84
+ - Single-piece token masking
85
+ - Final train loss: 1.0679
86
+ - Final train accuracy: 76.42%
87
+
88
+ **Stage 2 — Continued Pretraining with Whole-Word Masking (25 epochs)**
89
+
90
+ - Objective: Whole-word masked language modeling at 18% mask rate
91
+ - Initialized from Stage 1 checkpoint
92
+ - Final train loss: 0.8958
93
+ - Final train accuracy: 79.66%
94
+
95
+ ### Optimizer
96
+
97
+ Custom dual-optimizer setup combining AdamW (for embeddings, MLM head, residual lambdas, value gate parameters) and Muon (momentum-orthogonalized Newton-Schulz optimizer) for linear layer weight matrices.
98
+
99
+ ### Hyperparameters
100
+
101
+ - AdamW betas: (0.8, 0.95)
102
+ - Muon momentum: 0.95
103
+ - Embedding learning rate: 0.3
104
+ - Matrix learning rate: 0.02
105
+ - Weight decay: 0.0
106
+ - Precision: fp32 training, fp16 release
107
+
108
+ ## Evaluation
109
+
110
+ Evaluated on a 546-case domain-specific MLM benchmark. See [`EVAL.md`](EVAL.md) for full methodology, test case examples, and per-category breakdown.
111
+
112
+ | Metric | Value |
113
+ |---|---|
114
+ | Overall pass rate | 94.7% (517 / 546) |
115
+ | Difficulty-weighted | 94.6% |
116
+ | Easy | 94.9% |
117
+ | Medium | 94.9% |
118
+ | Hard | 94.2% |
119
+
120
+ ## Technical Specifications
121
+
122
+ | Property | Value |
123
+ |---|---|
124
+ | Framework | PyTorch 2.4+ / Transformers 5.2.0 |
125
+ | Format | safetensors (fp16) |
126
+ | Model size (disk) | 546 MB |
127
+ | Model size (fp32 memory) | ~1.0 GB |
128
+ | Tokenizer | `google-bert/bert-base-uncased` (bundled, see Attribution) |
129
+ | Custom code required | Yes (`trust_remote_code=True`) |
130
+
131
+ ### Architecture Detail
132
+
133
+ - 12 transformer blocks, hidden size 768, 8 attention heads (head dim 96)
134
+ - RoPE (Rotary Position Embedding) applied to Q and K projections
135
+ - RMS normalization on Q and K (no learnable gain)
136
+ - ReLU-squared MLP activation (`relu(x)²`)
137
+ - Gated value embeddings on even-indexed layers (0, 2, 4, 6, 8, 10)
138
+ - Learned residual interpolation: `λ_resid · block_output + λ_x0 · initial_embedding`
139
+ - MLM head: `Linear → GELU → RMSNorm → Linear`
140
+
141
+ ## Attribution
142
+
143
+ This repository redistributes the fast-tokenizer files (`tokenizer.json`, `tokenizer_config.json`) from [`google-bert/bert-base-uncased`](https://huggingface.co/google-bert/bert-base-uncased) unmodified. Those files are released by Google under the Apache License 2.0 and are bundled here so `AutoTokenizer.from_pretrained` and the Hugging Face Hub `fill-mask` widget work without an extra download step. TheoBERT Base is itself released under Apache 2.0; see `LICENSE`.
144
+
145
+ ## Citation
146
+
147
+ ```bibtex
148
+ @misc{theobertbase2026,
149
+ author = {Toran Billups},
150
+ title = {TheoBERT Base: A Domain-Specialized Masked Language Model for Biblical and Theological Text},
151
+ year = 2026,
152
+ publisher = {Hugging Face},
153
+ howpublished = {https://huggingface.co/toranb/theo-bert-base}
154
+ }
155
+ ```
README.md ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: apache-2.0
5
+ library_name: transformers
6
+ pipeline_tag: fill-mask
7
+ tags:
8
+ - masked-language-modeling
9
+ - bible
10
+ - theology
11
+ - christianity
12
+ - trust-remote-code
13
+ model-index:
14
+ - name: theo-bert-base
15
+ results:
16
+ - task:
17
+ type: fill-mask
18
+ name: Masked Language Modeling
19
+ metrics:
20
+ - type: accuracy
21
+ value: 0.947
22
+ name: Pass rate on 546-case theological MLM eval
23
+ ---
24
+
25
+ # TheoBERT Base
26
+
27
+ `theo-bert-base` is a domain-specialized **masked language model** for biblical and theological text. It is a custom bidirectional encoder pretrained from scratch on bible and closely related doctrinal material, exported in a Hugging Face–compatible format.
28
+
29
+ This repository ships the MLM-shaped artifact: an encoder body paired with a working MLM head. It is the right checkpoint if you want fill-mask, token-level scoring, or a strong base for further domain-specific fine-tuning where token-level prediction matters.
30
+
31
+
32
+ ## What This Model Is For
33
+
34
+ Recommended use cases:
35
+
36
+ - Masked token prediction and token-level scoring in biblical-domain text
37
+ - Initialization for continued domain adaptation or supervised downstream fine-tuning
38
+ - Encoder hidden states for downstream task heads (classification, NER, etc.)
39
+
40
+ ## Training Pipeline
41
+
42
+ This release is the output of a two-stage pretraining pipeline.
43
+
44
+ **Stage 1 — MLM pretraining from scratch (`encoder`)**
45
+ - 24 epochs of masked language modeling at 256-token context
46
+ - 270,000 sequences from bible text, Christian books, biblical commentaries and synthetic data
47
+ - Final train loss `1.0679`, train accuracy `76.42%`
48
+
49
+ **Stage 2 — Whole-word-masking continued pretraining (`mlmcontinued`)** — *this release*
50
+ - 25 additional epochs of continued pretraining on top of Stage 1
51
+ - 18% whole-word-masking rate (whole-word, not single-piece)
52
+ - Final train loss `0.8958`, train accuracy `79.66%`
53
+
54
+ The MLM head was trained jointly with the body throughout both stages and is preserved in this release.
55
+
56
+ ## Evaluation
57
+
58
+ Evaluated on a 546-case domain-specific MLM benchmark covering bibliology, christology, ecclesiology, eschatology, hamartiology, pneumatology, soteriology, theology proper, and canonical knowledge. Full methodology and test case schema in [`EVAL.md`](EVAL.md).
59
+
60
+ | Metric | Value |
61
+ |---|---|
62
+ | Overall pass rate | **94.7%** (517 / 546) |
63
+ | Difficulty-weighted | 94.6% |
64
+ | Easy | 94.9% |
65
+ | Medium | 94.9% |
66
+ | Hard | 94.2% |
67
+
68
+ Per-category highlights:
69
+
70
+ | Category | Pass rate |
71
+ |---|---|
72
+ | Pneumatology | 100% |
73
+ | Soteriology | 98.2% |
74
+ | Ecclesiology | 97.5% |
75
+ | Hamartiology | 97.1% |
76
+ | Christology | 96.4% |
77
+ | Eschatology | 94.4% |
78
+ | Theology proper | 91.3% |
79
+ | Canonical knowledge | 88.4% |
80
+
81
+ Residual failures cluster around Old Testament proper-noun recall (Jeremiah, Jonah, Job, Nebuchadnezzar) and multi-piece subword reconstruction (`sabachthani`, `iniquity`, `Nebuchadnezzar`). The benchmark suggests strong domain-specific MLM behavior on this suite; broader generalization beyond the eval distribution has not been independently verified.
82
+
83
+ ## Tokenizer
84
+
85
+ `theo-bert-base` uses the **`google-bert/bert-base-uncased` tokenizer**. The fast-tokenizer files (`tokenizer.json`, `tokenizer_config.json`) are bundled in this repo so `AutoTokenizer.from_pretrained("toranb/theo-bert-base")` and the Hub `fill-mask` widget work out of the box.
86
+
87
+ Tokenizer files are redistributed unmodified from [`google-bert/bert-base-uncased`](https://huggingface.co/google-bert/bert-base-uncased), released by Google under the Apache License 2.0.
88
+
89
+ ## Architecture
90
+
91
+ - 12 transformer blocks
92
+ - Hidden size 768
93
+ - 8 attention heads (head dim 96)
94
+ - Training sequence length 256 (rotary cache supports up to 2,560 tokens)
95
+ - Vocabulary size 30,522 via `bert-base-uncased`
96
+ - RoPE positional encoding applied to query and key projections
97
+ - RMS normalization on Q and K (no learnable gain)
98
+ - ReLU-squared MLP activation
99
+ - Gated value embeddings on even-indexed layers
100
+ - Learned residual interpolation between each block output and the initial token-embedding state
101
+ - MLM head: `Linear → GELU → RMSNorm → Linear`
102
+
103
+ Parameter count: **273,051,864** (≈273M).
104
+
105
+ ## Quick Start — Fill-Mask
106
+
107
+ ```python
108
+ from transformers import AutoModelForMaskedLM, AutoTokenizer
109
+
110
+ repo = "toranb/theo-bert-base"
111
+
112
+ tokenizer = AutoTokenizer.from_pretrained(repo)
113
+ model = AutoModelForMaskedLM.from_pretrained(repo, trust_remote_code=True)
114
+ model.eval()
115
+
116
+ inputs = tokenizer(
117
+ "For God so loved the [MASK] that he gave his only Son.",
118
+ return_tensors="pt",
119
+ )
120
+ outputs = model(**inputs)
121
+ mask_index = (inputs["input_ids"] == tokenizer.mask_token_id).nonzero(as_tuple=False)[0, 1]
122
+ top_ids = outputs.logits[0, mask_index].topk(5).indices.tolist()
123
+ print(tokenizer.convert_ids_to_tokens(top_ids))
124
+ # → ['world', 'universe', 'son', 'church', 'earth']
125
+ ```
126
+
127
+ ## Quick Start — Encoder Hidden States
128
+
129
+ ```python
130
+ from transformers import AutoModel, AutoTokenizer
131
+
132
+ repo = "toranb/theo-bert-base"
133
+
134
+ tokenizer = AutoTokenizer.from_pretrained(repo)
135
+ model = AutoModel.from_pretrained(repo, trust_remote_code=True)
136
+ model.eval()
137
+
138
+ batch = tokenizer(
139
+ ["faith working through love", "the kingdom of God"],
140
+ padding=True, truncation=True, max_length=256, return_tensors="pt",
141
+ )
142
+ hidden = model(**batch).last_hidden_state # [B, T, 768]
143
+ mask = batch["attention_mask"].unsqueeze(-1).float()
144
+ pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1)
145
+ ```
146
+
147
+
148
+ ## Repository Contents
149
+
150
+ | File | Purpose |
151
+ |---|---|
152
+ | `configuration_theo_bert_base.py` | Hugging Face config class |
153
+ | `modeling_theo_bert_base.py` | `AutoModel` and `AutoModelForMaskedLM` implementations |
154
+ | `muon.py` | Local Muon optimizer (retained for self-contained fine-tuning) |
155
+ | `config.json` | Generated from the source checkpoint configuration |
156
+ | `model.safetensors` | Released fp16 weights |
157
+ | `checkpoint_metadata.json` | Source checkpoint and per-stage training metadata |
158
+ | `LICENSE` | Apache-2.0 |
159
+
160
+ ### Scripts
161
+
162
+ | Script | Purpose |
163
+ |---|---|
164
+ | `scripts/mlm_eval_safetensors.py` | Loads `model.safetensors` + `eval.json` and runs the full 546-case MLM evaluation suite |
165
+
166
+ ## Limitations
167
+
168
+ - Specialized for biblical and theological language; may underperform on broad general-domain NLP tasks.
169
+ - Tokenizer inherited from `bert-base-uncased`, so wordpiece behavior follows general English conventions rather than a theology-specific tokenizer.
170
+ - Trained at 256-token context. Longer inputs work within the rotary cache (up to 2,560 tokens), but extended-context behavior is not a primary target of this release.
171
+ - Training data is private, so external auditing of corpus composition is limited. The canonical-knowledge eval cases overlap by design with biblical text that appears in the training corpus, so the 88.4% recall on that category should be read as in-distribution recall, not held-out generalization.
172
+ - Encoder MLM — not an autoregressive decoder.
173
+
174
+ ## Release Details
175
+
176
+ - Exported from `mlmcontinued/latest.pt` (Stage 2 final epoch, training accuracy 79.66%)
177
+ - Source checkpoint loss `0.8958`
178
+ - Released weights in fp16 for bandwidth efficiency (546 MB)
179
+ - Release format uses `safetensors`
180
+ - Loading requires `trust_remote_code=True` to register the custom architecture
181
+ - `config.json` declares `torch_dtype: float32` so default loads upcast on read. Disk weights stay fp16 (small download); CPU inference is numerically safe by default. For GPU fp16 inference, pass `dtype=torch.float16` to `from_pretrained`.
__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .configuration_theo_bert_base import TheoBertBaseConfig
2
+ from .modeling_theo_bert_base import (
3
+ TheoBertBaseForMaskedLM,
4
+ TheoBertBaseModel,
5
+ )
6
+
7
+ __all__ = [
8
+ "TheoBertBaseConfig",
9
+ "TheoBertBaseModel",
10
+ "TheoBertBaseForMaskedLM",
11
+ ]
checkpoint_metadata.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "release_stage": "mlmcontinued (stage 2, epoch 25)",
3
+ "reported_loss": 0.8958267427277438,
4
+ "parameter_count": 273051864,
5
+ "fp16_export": true,
6
+ "tokenizer": "google-bert/bert-base-uncased",
7
+ "pretraining_stages": [
8
+ {
9
+ "stage": 1,
10
+ "name": "encoder",
11
+ "objective": "token-level masked language modeling at 20% mask rate 80/10/10 split",
12
+ "epochs": 24,
13
+ "seq_len": 256,
14
+ "final_train_loss": 1.0678829201169648,
15
+ "final_train_accuracy": 76.41802635495705
16
+ },
17
+ {
18
+ "stage": 2,
19
+ "name": "mlmcontinued",
20
+ "objective": "whole-word-masking continued pretraining at 18% mask rate",
21
+ "epochs": 25,
22
+ "seq_len": 256,
23
+ "final_train_loss": 0.8958267427277438,
24
+ "final_train_accuracy": 79.66191907459469
25
+ }
26
+ ],
27
+ "mlm_eval_overall_pass_rate": 0.947,
28
+ "mlm_eval_passed_cases": 517,
29
+ "mlm_eval_total_cases": 546
30
+ }
config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "TheoBertBaseForMaskedLM"
4
+ ],
5
+ "model_type": "theo_bert_base",
6
+ "auto_map": {
7
+ "AutoConfig": "configuration_theo_bert_base.TheoBertBaseConfig",
8
+ "AutoModel": "modeling_theo_bert_base.TheoBertBaseModel",
9
+ "AutoModelForMaskedLM": "modeling_theo_bert_base.TheoBertBaseForMaskedLM"
10
+ },
11
+ "tokenizer_name": "google-bert/bert-base-uncased",
12
+ "torch_dtype": "float32",
13
+ "vocab_size": 30522,
14
+ "n_layer": 12,
15
+ "n_head": 8,
16
+ "n_embd": 768,
17
+ "seq_len": 256,
18
+ "rope_base": 10000,
19
+ "rope_cache_factor": 10,
20
+ "hidden_size": 768,
21
+ "num_hidden_layers": 12,
22
+ "num_attention_heads": 8,
23
+ "max_position_embeddings": 256,
24
+ "pad_token_id": 0,
25
+ "unk_token_id": 100,
26
+ "cls_token_id": 101,
27
+ "sep_token_id": 102,
28
+ "mask_token_id": 103,
29
+ "transformers_version": "5.2.0"
30
+ }
configuration_theo_bert_base.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import PretrainedConfig
2
+
3
+
4
+ class TheoBertBaseConfig(PretrainedConfig):
5
+ model_type = "theo_bert_base"
6
+
7
+ def __init__(
8
+ self,
9
+ vocab_size=30522,
10
+ n_layer=12,
11
+ n_head=8,
12
+ n_embd=768,
13
+ seq_len=256,
14
+ rope_base=10000,
15
+ rope_cache_factor=10,
16
+ pad_token_id=0,
17
+ unk_token_id=100,
18
+ cls_token_id=101,
19
+ sep_token_id=102,
20
+ mask_token_id=103,
21
+ tokenizer_name="google-bert/bert-base-uncased",
22
+ **kwargs,
23
+ ):
24
+ self.vocab_size = vocab_size
25
+ self.n_layer = n_layer
26
+ self.n_head = n_head
27
+ self.n_embd = n_embd
28
+ self.seq_len = seq_len
29
+ self.rope_base = rope_base
30
+ self.rope_cache_factor = rope_cache_factor
31
+ self.tokenizer_name = tokenizer_name
32
+
33
+ # Compatibility aliases expected by downstream tooling.
34
+ self.hidden_size = n_embd
35
+ self.num_hidden_layers = n_layer
36
+ self.num_attention_heads = n_head
37
+ self.max_position_embeddings = seq_len
38
+ self.rope_cache_seq_len = seq_len * rope_cache_factor
39
+
40
+ super().__init__(
41
+ pad_token_id=pad_token_id,
42
+ unk_token_id=unk_token_id,
43
+ cls_token_id=cls_token_id,
44
+ sep_token_id=sep_token_id,
45
+ mask_token_id=mask_token_id,
46
+ **kwargs,
47
+ )
eval.json ADDED
The diff for this file is too large to render. See raw diff
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb9425642090f3523127bd8808f9a84c053c8c25f101775d0d759a460aad0c3a
3
+ size 546114384
modeling_theo_bert_base.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from typing import Optional
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ import torch.nn.functional as F
7
+ from transformers import PreTrainedModel
8
+ from transformers.modeling_outputs import BaseModelOutput, MaskedLMOutput
9
+
10
+ from .configuration_theo_bert_base import TheoBertBaseConfig
11
+ from .muon import Muon
12
+
13
+
14
+ def norm(x: torch.Tensor) -> torch.Tensor:
15
+ return F.rms_norm(x, (x.size(-1),))
16
+
17
+
18
+ def apply_rotary_emb(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
19
+ d = x.shape[-1] // 2
20
+ x1, x2 = x[..., :d], x[..., d:]
21
+ return torch.cat([x1 * cos + x2 * sin, x1 * (-sin) + x2 * cos], dim=-1).to(x.dtype)
22
+
23
+
24
+ class RMSNorm(nn.Module):
25
+ def __init__(self, dim: int):
26
+ super().__init__()
27
+ self.dim = dim
28
+
29
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
30
+ return F.rms_norm(x, (self.dim,))
31
+
32
+
33
+ class SelfAttention(nn.Module):
34
+ def __init__(self, config: TheoBertBaseConfig):
35
+ super().__init__()
36
+ self.n_head = config.n_head
37
+ self.head_dim = config.n_embd // config.n_head
38
+ D = config.n_embd
39
+ self.c_q = nn.Linear(D, D, bias=False)
40
+ self.c_k = nn.Linear(D, D, bias=False)
41
+ self.c_v = nn.Linear(D, D, bias=False)
42
+ self.c_proj = nn.Linear(D, D, bias=False)
43
+
44
+ def forward(self, x, cos_sin, ve=None, attention_mask=None):
45
+ B, T, D = x.shape
46
+ H, Dh = self.n_head, self.head_dim
47
+
48
+ q = self.c_q(x).view(B, T, H, Dh)
49
+ k = self.c_k(x).view(B, T, H, Dh)
50
+
51
+ v_proj = self.c_v(x)
52
+ if ve is not None:
53
+ v_proj = v_proj + ve
54
+ v = v_proj.view(B, T, H, Dh)
55
+
56
+ cos, sin = cos_sin
57
+ q = apply_rotary_emb(q, cos, sin)
58
+ k = apply_rotary_emb(k, cos, sin)
59
+ q = norm(q)
60
+ k = norm(k)
61
+
62
+ q, k, v = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2)
63
+
64
+ attn_mask = None
65
+ if attention_mask is not None:
66
+ attn_mask = attention_mask[:, None, None, :].bool()
67
+ y = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask, is_causal=False)
68
+ return self.c_proj(y.transpose(1, 2).contiguous().view(B, T, D))
69
+
70
+
71
+ class MLP(nn.Module):
72
+ def __init__(self, config: TheoBertBaseConfig):
73
+ super().__init__()
74
+ D = config.n_embd
75
+ self.c_fc = nn.Linear(D, 4 * D, bias=False)
76
+ self.c_proj = nn.Linear(4 * D, D, bias=False)
77
+
78
+ def forward(self, x):
79
+ return self.c_proj(F.relu(self.c_fc(x)).square())
80
+
81
+
82
+ class Block(nn.Module):
83
+ def __init__(self, config: TheoBertBaseConfig, layer_idx: int):
84
+ super().__init__()
85
+ self.attn = SelfAttention(config)
86
+ self.mlp = MLP(config)
87
+ self.resid_lambda = nn.Parameter(torch.ones(1))
88
+ self.x0_lambda = nn.Parameter(torch.full((1,), 0.1))
89
+ if layer_idx % 2 == 0:
90
+ self.value_embed = nn.Embedding(config.vocab_size, config.n_embd)
91
+ self.ve_gate = nn.Linear(32, 1, bias=False)
92
+
93
+ def forward(self, x, cos_sin, x0, token_ids, attention_mask=None):
94
+ normed = norm(x)
95
+ ve = None
96
+ if hasattr(self, "value_embed"):
97
+ raw_ve = self.value_embed(token_ids)
98
+ gate = 2 * torch.sigmoid(self.ve_gate(normed[..., :32]))
99
+ ve = gate * raw_ve
100
+ x = x + self.attn(normed, cos_sin, ve=ve, attention_mask=attention_mask)
101
+ x = x + self.mlp(norm(x))
102
+ return self.resid_lambda * x + self.x0_lambda * x0
103
+
104
+
105
+ class TheoBertBasePreTrainedModel(PreTrainedModel):
106
+ config_class = TheoBertBaseConfig
107
+ base_model_prefix = "theo_bert_base"
108
+ supports_gradient_checkpointing = True
109
+
110
+ def _init_weights(self, module):
111
+ if isinstance(module, nn.Linear):
112
+ fan_in = module.weight.size(1)
113
+ fan_out = module.weight.size(0)
114
+ std = (1.0 / math.sqrt(fan_in)) * min(1.0, math.sqrt(fan_out / fan_in))
115
+ nn.init.normal_(module.weight, std=std)
116
+ elif isinstance(module, nn.Embedding):
117
+ nn.init.normal_(module.weight, std=1.0)
118
+
119
+ def _set_gradient_checkpointing(self, module, value=False):
120
+ if isinstance(module, TheoBertBaseModel):
121
+ module.use_gradient_checkpointing = value
122
+
123
+
124
+ class TheoBertBaseModel(TheoBertBasePreTrainedModel):
125
+ def __init__(self, config: TheoBertBaseConfig):
126
+ super().__init__(config)
127
+ self.use_gradient_checkpointing = False
128
+ self.wte = nn.Embedding(config.vocab_size, config.n_embd)
129
+ self.blocks = nn.ModuleList([Block(config, i) for i in range(config.n_layer)])
130
+ # Retained on the base model so the exported checkpoint can be consumed by
131
+ # AutoModel and AutoModelForMaskedLM from the same repository without key drift.
132
+ self.mlm_head = nn.Sequential(
133
+ nn.Linear(config.n_embd, config.n_embd, bias=False),
134
+ nn.GELU(),
135
+ RMSNorm(config.n_embd),
136
+ nn.Linear(config.n_embd, config.vocab_size, bias=False),
137
+ )
138
+ self._refresh_rope_cache()
139
+ self.post_init()
140
+ self._post_init_architecture()
141
+
142
+ def _post_init_architecture(self):
143
+ nn.init.zeros_(self.mlm_head[-1].weight)
144
+ for block in self.blocks:
145
+ nn.init.zeros_(block.attn.c_proj.weight)
146
+ nn.init.zeros_(block.mlp.c_proj.weight)
147
+ nn.init.ones_(block.resid_lambda)
148
+ block.x0_lambda.data.fill_(0.1)
149
+
150
+ def _make_rotary(self, seq_len, head_dim, base=10000, device=None):
151
+ if device is None:
152
+ device = self.wte.weight.device
153
+ inv_freq = 1.0 / (
154
+ base
155
+ ** (torch.arange(0, head_dim, 2, dtype=torch.float32, device=device) / head_dim)
156
+ )
157
+ t = torch.arange(seq_len, dtype=torch.float32, device=device)
158
+ freqs = torch.outer(t, inv_freq)
159
+ cos = freqs.cos()[None, :, None, :]
160
+ sin = freqs.sin()[None, :, None, :]
161
+ return cos, sin
162
+
163
+ def _refresh_rope_cache(self):
164
+ head_dim = self.config.n_embd // self.config.n_head
165
+ cache_len = self.config.seq_len * self.config.rope_cache_factor
166
+ cos, sin = self._make_rotary(cache_len, head_dim, base=self.config.rope_base)
167
+ self.register_buffer("cos", cos, persistent=False)
168
+ self.register_buffer("sin", sin, persistent=False)
169
+
170
+ def get_input_embeddings(self):
171
+ return self.wte
172
+
173
+ def set_input_embeddings(self, value):
174
+ self.wte = value
175
+
176
+ def mean_pool(self, hidden, mask=None):
177
+ if mask is None:
178
+ return hidden.mean(dim=1)
179
+ m = mask.unsqueeze(-1).float()
180
+ return (hidden * m).sum(1) / m.sum(1).clamp(min=1)
181
+
182
+ def setup_optimizers(self, embedding_lr=0.3, matrix_lr=0.02):
183
+ model_dim = self.config.n_embd
184
+ mlm_head_lr = 0.004 * math.sqrt(768 / model_dim)
185
+
186
+ embed_params = list(self.wte.parameters())
187
+ ve_params, ve_gate_params, resid_params, x0_params, matrix_params = [], [], [], [], []
188
+
189
+ for block in self.blocks:
190
+ matrix_params += [
191
+ block.attn.c_q.weight,
192
+ block.attn.c_k.weight,
193
+ block.attn.c_v.weight,
194
+ block.attn.c_proj.weight,
195
+ block.mlp.c_fc.weight,
196
+ block.mlp.c_proj.weight,
197
+ ]
198
+ resid_params.append(block.resid_lambda)
199
+ x0_params.append(block.x0_lambda)
200
+ if hasattr(block, "value_embed"):
201
+ ve_params += list(block.value_embed.parameters())
202
+ ve_gate_params += list(block.ve_gate.parameters())
203
+
204
+ adamw_groups = [
205
+ {"params": embed_params + ve_params, "lr": embedding_lr},
206
+ {"params": list(self.mlm_head.parameters()), "lr": mlm_head_lr},
207
+ {"params": resid_params, "lr": 0.005},
208
+ {"params": x0_params, "lr": 0.5, "betas": (0.96, 0.95)},
209
+ {"params": ve_gate_params, "lr": 0.004},
210
+ ]
211
+ adamw_groups = [g for g in adamw_groups if g["params"]]
212
+
213
+ adamw = torch.optim.AdamW(
214
+ adamw_groups, betas=(0.8, 0.95), eps=1e-10, weight_decay=0.0, fused=True
215
+ )
216
+ muon = Muon(matrix_params, lr=matrix_lr, momentum=0.95)
217
+
218
+ for opt in (adamw, muon):
219
+ for group in opt.param_groups:
220
+ group["initial_lr"] = group["lr"]
221
+ return adamw, muon
222
+
223
+ def forward(
224
+ self,
225
+ input_ids: torch.LongTensor,
226
+ attention_mask: Optional[torch.Tensor] = None,
227
+ output_hidden_states: Optional[bool] = None,
228
+ return_dict: Optional[bool] = None,
229
+ **kwargs,
230
+ ):
231
+ del kwargs
232
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
233
+ output_hidden_states = (
234
+ output_hidden_states
235
+ if output_hidden_states is not None
236
+ else self.config.output_hidden_states
237
+ )
238
+
239
+ # Non-persistent rotary buffers are skipped by state_dict load; after
240
+ # from_pretrained's meta→to_empty path they hold uninitialized memory.
241
+ # Refresh once per instance, on the actual parameter device.
242
+ if not getattr(self, "_rope_initialized", False):
243
+ self._refresh_rope_cache()
244
+ self._rope_initialized = True
245
+
246
+ B, T = input_ids.shape
247
+ if T > self.cos.size(1):
248
+ raise ValueError(
249
+ f"Input sequence length {T} exceeds rotary cache length {self.cos.size(1)}."
250
+ )
251
+ cos_sin = self.cos[:, :T], self.sin[:, :T]
252
+ x = norm(self.wte(input_ids))
253
+ x0 = x
254
+
255
+ hidden_states = () if output_hidden_states else None
256
+ if output_hidden_states:
257
+ hidden_states = hidden_states + (x,)
258
+
259
+ if self.training and self.use_gradient_checkpointing:
260
+ from torch.utils.checkpoint import checkpoint
261
+
262
+ for block in self.blocks:
263
+ x = checkpoint(block, x, cos_sin, x0, input_ids, attention_mask, use_reentrant=False)
264
+ if output_hidden_states:
265
+ hidden_states = hidden_states + (x,)
266
+ else:
267
+ for block in self.blocks:
268
+ x = block(x, cos_sin, x0, input_ids, attention_mask=attention_mask)
269
+ if output_hidden_states:
270
+ hidden_states = hidden_states + (x,)
271
+
272
+ x = norm(x)
273
+ if not return_dict:
274
+ return (x, hidden_states)
275
+ return BaseModelOutput(last_hidden_state=x, hidden_states=hidden_states)
276
+
277
+
278
+ class TheoBertBaseForMaskedLM(TheoBertBaseModel):
279
+ _keys_to_ignore_on_load_unexpected = [r"cls\..*"]
280
+
281
+ def get_output_embeddings(self):
282
+ return self.mlm_head[-1]
283
+
284
+ def set_output_embeddings(self, new_embeddings):
285
+ self.mlm_head[-1] = new_embeddings
286
+
287
+ def forward(
288
+ self,
289
+ input_ids: torch.LongTensor,
290
+ attention_mask: Optional[torch.Tensor] = None,
291
+ labels: Optional[torch.LongTensor] = None,
292
+ output_hidden_states: Optional[bool] = None,
293
+ return_dict: Optional[bool] = None,
294
+ **kwargs,
295
+ ):
296
+ outputs = super().forward(
297
+ input_ids=input_ids,
298
+ attention_mask=attention_mask,
299
+ output_hidden_states=output_hidden_states,
300
+ return_dict=True,
301
+ **kwargs,
302
+ )
303
+ logits = self.mlm_head(outputs.last_hidden_state).float()
304
+
305
+ loss = None
306
+ if labels is not None:
307
+ loss = F.cross_entropy(
308
+ logits.view(-1, self.config.vocab_size),
309
+ labels.view(-1),
310
+ ignore_index=-100,
311
+ )
312
+
313
+ if return_dict is False:
314
+ result = (logits, outputs.hidden_states)
315
+ return ((loss,) + result) if loss is not None else result
316
+
317
+ return MaskedLMOutput(loss=loss, logits=logits, hidden_states=outputs.hidden_states)
muon.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Muon optimizer -- MomentUm Orthogonalized by Newton-Schulz.
3
+ https://kellerjordan.github.io/posts/muon/
4
+
5
+ Included locally so this repository is self-contained for anyone who wants to
6
+ fine-tune or continue pretraining from the exported checkpoint.
7
+ """
8
+
9
+ import torch
10
+ from torch import Tensor
11
+
12
+
13
+ @torch.compile
14
+ def zeropower_via_newtonschulz5(G: Tensor, steps: int) -> Tensor:
15
+ assert G.ndim >= 2
16
+ a, b, c = (3.4445, -4.7750, 2.0315)
17
+ X = G.bfloat16()
18
+ if G.size(-2) > G.size(-1):
19
+ X = X.mT
20
+ X = X / (X.norm(dim=(-2, -1), keepdim=True) + 1e-7)
21
+ for _ in range(steps):
22
+ A = X @ X.mT
23
+ B = b * A + c * A @ A
24
+ X = a * X + B @ X
25
+ if G.size(-2) > G.size(-1):
26
+ X = X.mT
27
+ return X
28
+
29
+
30
+ class Muon(torch.optim.Optimizer):
31
+ def __init__(self, params, lr=0.02, momentum=0.95, nesterov=True, ns_steps=5):
32
+ defaults = dict(lr=lr, momentum=momentum, nesterov=nesterov, ns_steps=ns_steps)
33
+ params = [*params]
34
+ param_groups = []
35
+ for size in {p.numel() for p in params}:
36
+ param_groups.append(dict(params=[p for p in params if p.numel() == size]))
37
+ super().__init__(param_groups, defaults)
38
+
39
+ @torch.no_grad()
40
+ def step(self):
41
+ for group in self.param_groups:
42
+ for p in group["params"]:
43
+ g = p.grad
44
+ assert g is not None
45
+ state = self.state[p]
46
+ if "momentum_buffer" not in state:
47
+ state["momentum_buffer"] = torch.zeros_like(g)
48
+ buf = state["momentum_buffer"]
49
+ buf.lerp_(g, 1 - group["momentum"])
50
+ g = g.lerp_(buf, group["momentum"]) if group["nesterov"] else buf
51
+ g = zeropower_via_newtonschulz5(g, steps=group["ns_steps"])
52
+ p.add_(g, alpha=-group["lr"] * max(1, p.size(-2) / p.size(-1)) ** 0.5)
pyproject.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "theo-bert-base"
3
+ version = "0.1.0"
4
+ description = "Standalone Hugging Face export for a custom biblical-domain masked language model."
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "torch>=2.4.0",
8
+ "transformers>=5.2.0",
9
+ "safetensors>=0.7.0",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ torch>=2.4.0
2
+ transformers>=5.2.0
3
+ safetensors==0.7.0
scripts/mlm_eval_safetensors.py ADDED
@@ -0,0 +1,1138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MLM evaluation for safetensors-format bareminn encoder weights (fp16).
3
+
4
+ Loads a model.safetensors file directly, converts weights to fp32 for
5
+ bit-exact evaluation, and runs the full eval suite to prove the fp16-
6
+ stored weights produce identical results to the original fp32 checkpoint.
7
+
8
+ Usage:
9
+ python scripts/mlm_eval_safetensors.py --repo-dir . --device cpu
10
+ python scripts/mlm_eval_safetensors.py --repo-dir . --device cuda --compare eval_results/d12_encoder_mlm_eval.json
11
+ """
12
+
13
+ import os
14
+ import json
15
+ import argparse
16
+ from collections import Counter
17
+ import math
18
+
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from transformers import BertTokenizer
22
+
23
+ from pathlib import Path
24
+ from transformers import AutoModelForMaskedLM
25
+
26
+
27
+ MAX_LENGTH = 104
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Device resolution
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def resolve_device(requested: str) -> str:
35
+ if requested == "cuda" and not torch.cuda.is_available():
36
+ raise RuntimeError(
37
+ "Requested --device cuda, but torch.cuda.is_available() is False."
38
+ )
39
+ return requested
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Safetensors loader (replaces load_model for .pt checkpoints)
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def load_model_from_repo(repo_dir: Path, device: str):
47
+ """Load encoder weights from a Hugging Face repo directory (config.json +
48
+ model.safetensors + tokenizer files).
49
+
50
+ Weights on disk are fp16; we load at fp32 so the forward pass is
51
+ numerically identical to the original fp32 checkpoint — proving the
52
+ fp16→fp32 round-trip is lossless for these values.
53
+ """
54
+ print(f" Loading from repo: {repo_dir}")
55
+ model = AutoModelForMaskedLM.from_pretrained(
56
+ str(repo_dir),
57
+ dtype=torch.float32,
58
+ trust_remote_code=True,
59
+ )
60
+ model.to(device)
61
+ model.eval()
62
+ return model, model.config
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Tokenization helpers (unchanged from original)
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def tokenize_with_mask(text: str, tokenizer: BertTokenizer) -> list[int]:
70
+ lowered = "[MASK]".join(part.lower() for part in text.split("[MASK]"))
71
+ enc = tokenizer(lowered, add_special_tokens=False)
72
+ return enc["input_ids"][:MAX_LENGTH]
73
+
74
+
75
+ def word_to_token_ids(word: str, tokenizer: BertTokenizer) -> list[int]:
76
+ enc = tokenizer.encode(word.lower(), add_special_tokens=True)
77
+ return enc[1:-1]
78
+
79
+
80
+ def word_to_first_token_id(word: str, tokenizer: BertTokenizer) -> int:
81
+ inner = word_to_token_ids(word, tokenizer)
82
+ return inner[0] if inner else tokenizer.unk_token_id
83
+
84
+
85
+ def find_mask_positions(token_ids: list[int], mask_id: int) -> list[int]:
86
+ return [i for i, t in enumerate(token_ids) if t == mask_id]
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Candidate / sequence helpers (unchanged from original)
91
+ # ---------------------------------------------------------------------------
92
+
93
+ def _build_candidates(tc: dict) -> list[str]:
94
+ return (
95
+ tc.get("targets", []) +
96
+ tc.get("foils", []) +
97
+ tc.get("acceptable_alternatives", []) +
98
+ tc.get("failure_examples", [])
99
+ )
100
+
101
+
102
+ def _required_mask_count(tc: dict, tokenizer: BertTokenizer) -> int:
103
+ existing_masks = tc["input"].count("[MASK]")
104
+ targets = tc.get("targets", [])
105
+ required = max((len(word_to_token_ids(w, tokenizer)) for w in targets), default=1)
106
+ return max(existing_masks, required, 1)
107
+
108
+
109
+ def _expand_masks(text: str, required_masks: int) -> str:
110
+ existing_masks = text.count("[MASK]")
111
+ if existing_masks != 1 or required_masks <= 1:
112
+ return text
113
+ return text.replace("[MASK]", " ".join(["[MASK]"] * required_masks), 1)
114
+
115
+
116
+ def _sequence_candidates(
117
+ tc: dict,
118
+ tokenizer: BertTokenizer,
119
+ include_failures: bool = False,
120
+ ) -> list[tuple[str, tuple[int, ...]]]:
121
+ seen: set[tuple[int, ...]] = set()
122
+ words = tc.get("targets", []) + tc.get("acceptable_alternatives", [])
123
+ if tc["pass_condition"] == "correct_beats_foil":
124
+ words += tc.get("foils", [])
125
+ if include_failures:
126
+ words += tc.get("failure_examples", [])
127
+
128
+ out = []
129
+ for word in words:
130
+ ids = tuple(word_to_token_ids(word, tokenizer))
131
+ if not ids or ids in seen:
132
+ continue
133
+ seen.add(ids)
134
+ out.append((word, ids))
135
+ return out
136
+
137
+
138
+ def _sequence_probability(
139
+ probs_i: torch.Tensor,
140
+ mask_pos: list[int],
141
+ token_ids: list[int] | tuple[int, ...],
142
+ ) -> float:
143
+ if not token_ids or len(token_ids) > len(mask_pos):
144
+ return 0.0
145
+ log_probs = []
146
+ for pos, token_id in zip(mask_pos, token_ids):
147
+ prob = probs_i[pos, token_id].item()
148
+ log_probs.append(math.log(max(prob, 1e-45)))
149
+ return math.exp(sum(log_probs) / len(log_probs))
150
+
151
+
152
+ def _best_windowed_sequence_probability(
153
+ probs_i: torch.Tensor,
154
+ mask_pos: list[int],
155
+ token_ids: list[int] | tuple[int, ...],
156
+ ) -> float:
157
+ if not token_ids or len(token_ids) > len(mask_pos):
158
+ return 0.0
159
+ best = 0.0
160
+ width = len(token_ids)
161
+ for start in range(0, len(mask_pos) - width + 1):
162
+ prob = _sequence_probability(
163
+ probs_i, mask_pos[start : start + width], token_ids
164
+ )
165
+ best = max(best, prob)
166
+ return best
167
+
168
+
169
+ def _candidate_sequences(
170
+ words: list[str], tokenizer: BertTokenizer
171
+ ) -> list[tuple[str, tuple[int, ...]]]:
172
+ out = []
173
+ for word in words:
174
+ ids = tuple(word_to_token_ids(word, tokenizer))
175
+ if ids:
176
+ out.append((word, ids))
177
+ return out
178
+
179
+
180
+ def _contains_sequence(seq: tuple[int, ...], candidate: tuple[int, ...]) -> bool:
181
+ if not candidate or len(candidate) > len(seq):
182
+ return False
183
+ width = len(candidate)
184
+ return any(
185
+ seq[start : start + width] == candidate
186
+ for start in range(0, len(seq) - width + 1)
187
+ )
188
+
189
+
190
+ def _match_sequence(
191
+ seq: tuple[int, ...],
192
+ targets: list[tuple[str, tuple[int, ...]]],
193
+ alternatives: list[tuple[str, tuple[int, ...]]],
194
+ ) -> tuple[str, str | None]:
195
+ for word, ids in targets:
196
+ if seq == ids:
197
+ return "exact_target", word
198
+ for word, ids in alternatives:
199
+ if seq == ids:
200
+ return "exact_alt", word
201
+ for word, ids in alternatives:
202
+ if len(ids) < len(seq) and _contains_sequence(seq, ids):
203
+ return "contained_alt", word
204
+ return "none", None
205
+
206
+
207
+ def _matches_any_sequence(
208
+ seq: tuple[int, ...],
209
+ candidates: list[tuple[str, tuple[int, ...]]],
210
+ ) -> bool:
211
+ return any(
212
+ seq == ids or (len(ids) < len(seq) and _contains_sequence(seq, ids))
213
+ for _word, ids in candidates
214
+ )
215
+
216
+
217
+ def _best_candidate_probability(
218
+ probs_i: torch.Tensor,
219
+ mask_pos: list[int],
220
+ candidates: list[tuple[str, tuple[int, ...]]],
221
+ ) -> tuple[float, str | None]:
222
+ best_prob = 0.0
223
+ best_word = None
224
+ for word, ids in candidates:
225
+ prob = _best_windowed_sequence_probability(probs_i, mask_pos, ids)
226
+ if prob > best_prob:
227
+ best_prob = prob
228
+ best_word = word
229
+ return best_prob, best_word
230
+
231
+
232
+ def _token_candidates(mask_probs: torch.Tensor, k: int, n_samples: int) -> list[int]:
233
+ if n_samples <= 1:
234
+ return torch.topk(mask_probs, k).indices.tolist()
235
+ sampled: set[int] = set()
236
+ draw_k = min(k, mask_probs.numel())
237
+ for _ in range(n_samples):
238
+ drawn = torch.multinomial(mask_probs, draw_k, replacement=False)
239
+ sampled.update(drawn.tolist())
240
+ return sorted(sampled, key=lambda i: mask_probs[i].item(), reverse=True)
241
+
242
+
243
+ def _decode_token_sequence(
244
+ token_ids: list[int] | tuple[int, ...], tokenizer: BertTokenizer
245
+ ) -> str:
246
+ return tokenizer.convert_tokens_to_string(
247
+ tokenizer.convert_ids_to_tokens(list(token_ids))
248
+ ).strip()
249
+
250
+
251
+ def _top_k_sequences(
252
+ probs_i: torch.Tensor,
253
+ mask_pos: list[int],
254
+ k: int,
255
+ tokenizer: BertTokenizer,
256
+ n_samples: int,
257
+ beam_width: int | None = None,
258
+ ) -> list[tuple[tuple[int, ...], float]]:
259
+ if not mask_pos:
260
+ return []
261
+ beam_width = beam_width or max(20, k * 4)
262
+ candidates_per_mask = [
263
+ _token_candidates(probs_i[pos], beam_width, n_samples) for pos in mask_pos
264
+ ]
265
+ beams: list[tuple[tuple[int, ...], float]] = [(tuple(), 0.0)]
266
+ for pos, token_ids in zip(mask_pos, candidates_per_mask):
267
+ next_beams: list[tuple[tuple[int, ...], float]] = []
268
+ for prefix, score in beams:
269
+ for token_id in token_ids:
270
+ prob = probs_i[pos, token_id].item()
271
+ next_beams.append(
272
+ (prefix + (token_id,), score + math.log(max(prob, 1e-45)))
273
+ )
274
+ next_beams.sort(key=lambda item: item[1], reverse=True)
275
+ beams = next_beams[:beam_width]
276
+ return beams[:k]
277
+
278
+
279
+ def _classify_error(
280
+ tc: dict,
281
+ mask_probs: torch.Tensor,
282
+ top_k_ids: list[int],
283
+ failure_ids: set,
284
+ k: int,
285
+ passed: bool,
286
+ tokenizer: BertTokenizer,
287
+ ) -> str | None:
288
+ if passed:
289
+ return None
290
+ targets = tc.get("targets", [])
291
+ if not targets:
292
+ return "total_miss"
293
+ primary_correct_id = word_to_first_token_id(targets[0], tokenizer)
294
+ sorted_ids = torch.argsort(mask_probs, descending=True).tolist()
295
+ correct_rank = sorted_ids.index(primary_correct_id)
296
+ if k <= correct_rank <= k + 1:
297
+ return "near_miss"
298
+ if failure_ids and any(idx in failure_ids for idx in top_k_ids[:3]):
299
+ return "generic_over_theological"
300
+ if correct_rank >= 20:
301
+ return "total_miss"
302
+ return "wrong_semantic_cluster"
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Batching and inference (unchanged from original)
307
+ # ---------------------------------------------------------------------------
308
+
309
+ def prepare_batch(
310
+ test_cases: list[dict], tokenizer: BertTokenizer, device: str
311
+ ):
312
+ mask_id = tokenizer.mask_token_id
313
+ pad_id = tokenizer.pad_token_id
314
+
315
+ tokenized = []
316
+ mask_positions_list = []
317
+ multi_piece_flags = []
318
+ for tc in test_cases:
319
+ required_masks = _required_mask_count(tc, tokenizer)
320
+ original_masks = tc["input"].count("[MASK]")
321
+ expanded = _expand_masks(tc["input"], required_masks)
322
+ ids = tokenize_with_mask(expanded, tokenizer)
323
+ mpos = find_mask_positions(ids, mask_id)
324
+ tokenized.append(ids)
325
+ mask_positions_list.append(mpos)
326
+ multi_piece_flags.append(original_masks == 1 and required_masks > 1)
327
+
328
+ seq_len = max(len(ids) for ids in tokenized)
329
+ padded = [ids + [pad_id] * (seq_len - len(ids)) for ids in tokenized]
330
+ input_tensor = torch.tensor(padded, dtype=torch.long, device=device)
331
+ attention_mask = (input_tensor != pad_id).long()
332
+ return input_tensor, attention_mask, mask_positions_list, multi_piece_flags
333
+
334
+
335
+ @torch.no_grad()
336
+ def run_inference(
337
+ model,
338
+ input_tensor: torch.Tensor,
339
+ attention_mask: torch.Tensor,
340
+ temperature: float = 1.0,
341
+ ) -> torch.Tensor:
342
+ outputs = model(input_tensor, attention_mask=attention_mask)
343
+ logits = outputs.logits
344
+ if temperature != 1.0:
345
+ logits = logits / temperature
346
+ return F.softmax(logits, dim=-1)
347
+
348
+
349
+ def _predicted_tokens(
350
+ mask_probs: torch.Tensor,
351
+ k: int,
352
+ n_samples: int,
353
+ tokenizer: BertTokenizer,
354
+ ) -> list[str]:
355
+ ids = _token_candidates(mask_probs, k, n_samples)
356
+ return tokenizer.convert_ids_to_tokens(ids)
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Scoring functions (unchanged from original)
361
+ # ---------------------------------------------------------------------------
362
+
363
+ def score_target_in_top_k(
364
+ tc, probs_i, mask_pos, k, n_samples, tokenizer, multi_piece_target=False
365
+ ):
366
+ if multi_piece_target:
367
+ top_sequences = _top_k_sequences(
368
+ probs_i, mask_pos, k, tokenizer, n_samples
369
+ )
370
+ top_k_ids = [seq for seq, _ in top_sequences]
371
+ top_k_tokens = [
372
+ _decode_token_sequence(seq, tokenizer) for seq, _ in top_sequences
373
+ ]
374
+
375
+ targets = tc.get("targets", [])
376
+ alts = tc.get("acceptable_alternatives", [])
377
+ target_ids = _candidate_sequences(targets, tokenizer)
378
+ alt_ids = _candidate_sequences(alts, tokenizer)
379
+
380
+ passed = False
381
+ mrr = 0.0
382
+ match_type = "none"
383
+ matched_candidate = None
384
+ for rank, seq in enumerate(top_k_ids):
385
+ match_type, matched_candidate = _match_sequence(
386
+ seq, target_ids, alt_ids
387
+ )
388
+ if match_type != "none":
389
+ passed = True
390
+ mrr = 1.0 / (rank + 1)
391
+ break
392
+
393
+ failure_ids = _candidate_sequences(
394
+ tc.get("failure_examples", []), tokenizer
395
+ )
396
+ critical_failure = any(
397
+ _matches_any_sequence(seq, failure_ids) for seq in top_k_ids[:3]
398
+ )
399
+
400
+ target_rank = None
401
+ for _word, ids in target_ids:
402
+ for rank, beam in enumerate(top_k_ids):
403
+ if beam == ids:
404
+ target_rank = rank
405
+ break
406
+ if target_rank is not None:
407
+ break
408
+ if passed:
409
+ error_type = None
410
+ elif target_rank is not None and k <= target_rank <= k + 1:
411
+ error_type = "near_miss"
412
+ elif critical_failure:
413
+ error_type = "generic_over_theological"
414
+ else:
415
+ error_type = "total_miss"
416
+
417
+ return dict(
418
+ id=tc["id"],
419
+ type=tc["type"],
420
+ category=tc["category"],
421
+ difficulty=tc["difficulty"],
422
+ pass_=passed,
423
+ mrr=mrr,
424
+ margin=None,
425
+ confidence=None,
426
+ critical_failure=critical_failure,
427
+ masked_sentence=tc["input"],
428
+ correct_token=targets[0] if targets else "",
429
+ reference=tc.get("reference", ""),
430
+ candidates=_build_candidates(tc),
431
+ predicted_top_k=top_k_tokens,
432
+ error_type=error_type,
433
+ match_type=match_type,
434
+ matched_candidate=matched_candidate,
435
+ )
436
+
437
+ first_mask = mask_pos[0] if mask_pos else 0
438
+ mask_probs = probs_i[first_mask]
439
+ top_k_ids = torch.topk(mask_probs, k).indices.tolist()
440
+ top_k_tokens = _predicted_tokens(mask_probs, k, n_samples, tokenizer)
441
+
442
+ targets = tc.get("targets", [])
443
+ alts = tc.get("acceptable_alternatives", [])
444
+ correct_ids = set(
445
+ word_to_first_token_id(w, tokenizer) for w in targets + alts
446
+ )
447
+ passed = any(idx in correct_ids for idx in top_k_ids)
448
+
449
+ mrr = 0.0
450
+ for rank, idx in enumerate(top_k_ids):
451
+ if idx in correct_ids:
452
+ mrr = 1.0 / (rank + 1)
453
+ break
454
+
455
+ failure_ids = set(
456
+ word_to_first_token_id(w, tokenizer)
457
+ for w in tc.get("failure_examples", [])
458
+ )
459
+ critical_failure = any(idx in failure_ids for idx in top_k_ids[:3])
460
+ error_type = _classify_error(
461
+ tc, mask_probs, top_k_ids, failure_ids, k, passed, tokenizer
462
+ )
463
+
464
+ return dict(
465
+ id=tc["id"],
466
+ type=tc["type"],
467
+ category=tc["category"],
468
+ difficulty=tc["difficulty"],
469
+ pass_=passed,
470
+ mrr=mrr,
471
+ margin=None,
472
+ confidence=None,
473
+ critical_failure=critical_failure,
474
+ masked_sentence=tc["input"],
475
+ correct_token=targets[0] if targets else "",
476
+ reference=tc.get("reference", ""),
477
+ candidates=_build_candidates(tc),
478
+ predicted_top_k=top_k_tokens,
479
+ error_type=error_type,
480
+ )
481
+
482
+
483
+ def score_all_top_k_in_target_set(
484
+ tc, probs_i, mask_pos, k, n_samples, tokenizer, multi_piece_target=False
485
+ ):
486
+ if multi_piece_target:
487
+ top_sequences = _top_k_sequences(
488
+ probs_i, mask_pos, k, tokenizer, n_samples
489
+ )
490
+ top_k_ids = [seq for seq, _ in top_sequences]
491
+ top_k_tokens = [
492
+ _decode_token_sequence(seq, tokenizer) for seq, _ in top_sequences
493
+ ]
494
+
495
+ targets = tc.get("targets", [])
496
+ alts = tc.get("acceptable_alternatives", [])
497
+ target_ids = _candidate_sequences(targets, tokenizer)
498
+ alt_ids = _candidate_sequences(alts, tokenizer)
499
+
500
+ matches = [
501
+ _match_sequence(seq, target_ids, alt_ids) for seq in top_k_ids
502
+ ]
503
+ valid_count = sum(
504
+ 1 for match_type, _word in matches if match_type != "none"
505
+ )
506
+
507
+ precision = valid_count / k
508
+ passed = precision >= 0.8
509
+
510
+ failure_ids = _candidate_sequences(
511
+ tc.get("failure_examples", []), tokenizer
512
+ )
513
+ critical_failure = any(
514
+ _matches_any_sequence(seq, failure_ids) for seq in top_k_ids
515
+ )
516
+ error_type = (
517
+ None
518
+ if passed
519
+ else (
520
+ "generic_over_theological"
521
+ if critical_failure
522
+ else "wrong_semantic_cluster"
523
+ )
524
+ )
525
+ matched_candidates = [
526
+ word
527
+ for match_type, word in matches
528
+ if match_type != "none" and word is not None
529
+ ]
530
+
531
+ return dict(
532
+ id=tc["id"],
533
+ type=tc["type"],
534
+ category=tc["category"],
535
+ difficulty=tc["difficulty"],
536
+ pass_=passed,
537
+ mrr=None,
538
+ margin=precision,
539
+ confidence=None,
540
+ critical_failure=critical_failure,
541
+ masked_sentence=tc["input"],
542
+ correct_token=targets[0] if targets else "",
543
+ reference=tc.get("reference", ""),
544
+ candidates=_build_candidates(tc),
545
+ predicted_top_k=top_k_tokens,
546
+ error_type=error_type,
547
+ match_type="valid_set" if passed else "none",
548
+ matched_candidate=", ".join(matched_candidates)
549
+ if matched_candidates
550
+ else None,
551
+ )
552
+
553
+ first_mask = mask_pos[0] if mask_pos else 0
554
+ mask_probs = probs_i[first_mask]
555
+ top_k_ids = torch.topk(mask_probs, k).indices.tolist()
556
+ top_k_tokens = _predicted_tokens(mask_probs, k, n_samples, tokenizer)
557
+
558
+ targets = tc.get("targets", [])
559
+ alts = tc.get("acceptable_alternatives", [])
560
+ valid_ids = set(
561
+ word_to_first_token_id(w, tokenizer) for w in targets + alts
562
+ )
563
+
564
+ valid_count = sum(1 for idx in top_k_ids if idx in valid_ids)
565
+ precision = valid_count / k
566
+ passed = precision >= 0.8
567
+
568
+ failure_ids = set(
569
+ word_to_first_token_id(w, tokenizer)
570
+ for w in tc.get("failure_examples", [])
571
+ )
572
+ critical_failure = any(idx in failure_ids for idx in top_k_ids)
573
+ error_type = _classify_error(
574
+ tc, mask_probs, top_k_ids, failure_ids, k, passed, tokenizer
575
+ )
576
+
577
+ return dict(
578
+ id=tc["id"],
579
+ type=tc["type"],
580
+ category=tc["category"],
581
+ difficulty=tc["difficulty"],
582
+ pass_=passed,
583
+ mrr=None,
584
+ margin=precision,
585
+ confidence=None,
586
+ critical_failure=critical_failure,
587
+ masked_sentence=tc["input"],
588
+ correct_token=targets[0] if targets else "",
589
+ reference=tc.get("reference", ""),
590
+ candidates=_build_candidates(tc),
591
+ predicted_top_k=top_k_tokens,
592
+ error_type=error_type,
593
+ )
594
+
595
+
596
+ def score_correct_beats_foil(
597
+ tc, probs_i, mask_pos, k, n_samples, tokenizer, multi_piece_target=False
598
+ ):
599
+ if multi_piece_target:
600
+ targets = tc.get("targets", [])
601
+ alts = tc.get("acceptable_alternatives", [])
602
+ foils = tc.get("foils", [])
603
+
604
+ target_ids = _candidate_sequences(targets, tokenizer)
605
+ alt_ids = _candidate_sequences(alts, tokenizer)
606
+ p_correct, matched_candidate = _best_candidate_probability(
607
+ probs_i, mask_pos, target_ids + alt_ids
608
+ )
609
+ match_type = "none"
610
+ if matched_candidate is not None:
611
+ match_type = (
612
+ "exact_target"
613
+ if matched_candidate in targets
614
+ else "acceptable_alt"
615
+ )
616
+
617
+ foil_ids = word_to_token_ids(foils[0], tokenizer) if foils else []
618
+ p_foil = (
619
+ _best_windowed_sequence_probability(probs_i, mask_pos, foil_ids)
620
+ if foil_ids
621
+ else 0.0
622
+ )
623
+
624
+ passed = p_correct > p_foil
625
+ margin = p_correct - p_foil
626
+
627
+ if margin > 0.1:
628
+ confidence = "high"
629
+ elif margin > 0.02:
630
+ confidence = "medium"
631
+ else:
632
+ confidence = "low"
633
+
634
+ top_sequences = _top_k_sequences(
635
+ probs_i, mask_pos, k, tokenizer, n_samples
636
+ )
637
+ top_k_ids = [seq for seq, _ in top_sequences]
638
+ top_k_tokens = [
639
+ _decode_token_sequence(seq, tokenizer) for seq, _ in top_sequences
640
+ ]
641
+ failure_ids: set = set()
642
+ error_type = None if passed else "wrong_semantic_cluster"
643
+
644
+ return dict(
645
+ id=tc["id"],
646
+ type=tc["type"],
647
+ category=tc["category"],
648
+ difficulty=tc["difficulty"],
649
+ pass_=passed,
650
+ mrr=None,
651
+ margin=margin,
652
+ confidence=confidence,
653
+ critical_failure=False,
654
+ masked_sentence=tc["input"],
655
+ correct_token=targets[0] if targets else "",
656
+ reference=tc.get("reference", ""),
657
+ candidates=_build_candidates(tc),
658
+ predicted_top_k=top_k_tokens,
659
+ error_type=error_type,
660
+ match_type=match_type if passed else "none",
661
+ matched_candidate=matched_candidate if passed else None,
662
+ )
663
+
664
+ first_mask = mask_pos[0] if mask_pos else 0
665
+ mask_probs = probs_i[first_mask]
666
+
667
+ targets = tc.get("targets", [])
668
+ alts = tc.get("acceptable_alternatives", [])
669
+ foils = tc.get("foils", [])
670
+
671
+ correct_ids = [
672
+ word_to_first_token_id(w, tokenizer) for w in targets + alts
673
+ ]
674
+ p_correct = (
675
+ max(mask_probs[i].item() for i in correct_ids) if correct_ids else 0.0
676
+ )
677
+
678
+ foil_id = word_to_first_token_id(foils[0], tokenizer) if foils else None
679
+ p_foil = mask_probs[foil_id].item() if foil_id is not None else 0.0
680
+
681
+ passed = p_correct > p_foil
682
+ margin = p_correct - p_foil
683
+
684
+ if margin > 0.1:
685
+ confidence = "high"
686
+ elif margin > 0.02:
687
+ confidence = "medium"
688
+ else:
689
+ confidence = "low"
690
+
691
+ top_k_ids = torch.topk(mask_probs, k).indices.tolist()
692
+ top_k_tokens = _predicted_tokens(mask_probs, k, n_samples, tokenizer)
693
+ failure_ids: set = set()
694
+ error_type = _classify_error(
695
+ tc, mask_probs, top_k_ids, failure_ids, k, passed, tokenizer
696
+ )
697
+
698
+ return dict(
699
+ id=tc["id"],
700
+ type=tc["type"],
701
+ category=tc["category"],
702
+ difficulty=tc["difficulty"],
703
+ pass_=passed,
704
+ mrr=None,
705
+ margin=margin,
706
+ confidence=confidence,
707
+ critical_failure=False,
708
+ masked_sentence=tc["input"],
709
+ correct_token=targets[0] if targets else "",
710
+ reference=tc.get("reference", ""),
711
+ candidates=_build_candidates(tc),
712
+ predicted_top_k=top_k_tokens,
713
+ error_type=error_type,
714
+ )
715
+
716
+
717
+ def score_all(
718
+ test_cases,
719
+ probs,
720
+ mask_positions_list,
721
+ multi_piece_flags,
722
+ tokenizer,
723
+ default_k,
724
+ n_samples=1,
725
+ ):
726
+ results = []
727
+ for i, tc in enumerate(test_cases):
728
+ mpos = mask_positions_list[i]
729
+ multi_piece_target = multi_piece_flags[i]
730
+ k = tc.get("k") or default_k
731
+ cond = tc["pass_condition"]
732
+ probs_i = probs[i]
733
+
734
+ if cond == "target_in_top_k":
735
+ result = score_target_in_top_k(
736
+ tc,
737
+ probs_i,
738
+ mpos,
739
+ k,
740
+ n_samples,
741
+ tokenizer,
742
+ multi_piece_target=multi_piece_target,
743
+ )
744
+ elif cond == "all_top_k_in_target_set":
745
+ result = score_all_top_k_in_target_set(
746
+ tc,
747
+ probs_i,
748
+ mpos,
749
+ k,
750
+ n_samples,
751
+ tokenizer,
752
+ multi_piece_target=multi_piece_target,
753
+ )
754
+ elif cond == "correct_beats_foil":
755
+ result = score_correct_beats_foil(
756
+ tc,
757
+ probs_i,
758
+ mpos,
759
+ k,
760
+ n_samples,
761
+ tokenizer,
762
+ multi_piece_target=multi_piece_target,
763
+ )
764
+ else:
765
+ raise ValueError(f"Unknown pass_condition: {cond}")
766
+ results.append(result)
767
+ return results
768
+
769
+
770
+ # ---------------------------------------------------------------------------
771
+ # Reporting (unchanged from original)
772
+ # ---------------------------------------------------------------------------
773
+
774
+ def pct(val):
775
+ return f"{val * 100:.1f}%"
776
+
777
+
778
+ def difficulty_weighted_score(results):
779
+ weights = {"easy": 1.0, "medium": 2.0, "hard": 3.0}
780
+ ws = wt = 0.0
781
+ for result in results:
782
+ weight = weights.get(result["difficulty"], 1.0)
783
+ ws += weight if result["pass_"] else 0.0
784
+ wt += weight
785
+ return ws / wt if wt > 0 else 0.0
786
+
787
+
788
+ def print_results(results):
789
+ total = len(results)
790
+ passed = sum(1 for r in results if r["pass_"])
791
+ overall = passed / total
792
+
793
+ print("\n" + "=" * 70)
794
+ print("MLM EVALUATION RESULTS (safetensors fp16 → fp32)")
795
+ print("=" * 70)
796
+ print(f"\nOverall: {passed}/{total} ({pct(overall)})")
797
+
798
+ print("\n--- BY TYPE ---")
799
+ for eval_type in [
800
+ "doctrinal_association",
801
+ "canonical_knowledge",
802
+ "contrastive_theology",
803
+ ]:
804
+ matches = [r for r in results if r["type"] == eval_type]
805
+ passed_matches = sum(1 for r in matches if r["pass_"])
806
+ rate = passed_matches / len(matches) if matches else 0.0
807
+ extra = ""
808
+ if eval_type == "doctrinal_association":
809
+ mrrs = [r["mrr"] for r in matches if r["mrr"] is not None]
810
+ extra = (
811
+ f" mean_mrr={sum(mrrs) / len(mrrs):.3f}" if mrrs else ""
812
+ )
813
+ elif eval_type == "contrastive_theology":
814
+ margins = [r["margin"] for r in matches if r["margin"] is not None]
815
+ extra = (
816
+ f" mean_margin={sum(margins) / len(margins):.4f}"
817
+ if margins
818
+ else ""
819
+ )
820
+ print(
821
+ f" {eval_type}: {passed_matches}/{len(matches)} ({pct(rate)}){extra}"
822
+ )
823
+
824
+ print("\n--- BY CATEGORY ---")
825
+ categories = sorted(set(r["category"] for r in results))
826
+ for category in categories:
827
+ matches = [r for r in results if r["category"] == category]
828
+ passed_matches = sum(1 for r in matches if r["pass_"])
829
+ rate = passed_matches / len(matches) if matches else 0.0
830
+ critical = sum(1 for r in matches if r["critical_failure"])
831
+ print(
832
+ f" {category:<22s} {passed_matches}/{len(matches)} ({pct(rate):<6s}) crit_fail={critical}"
833
+ )
834
+
835
+ print("\n--- BY DIFFICULTY ---")
836
+ for difficulty in ["easy", "medium", "hard"]:
837
+ matches = [r for r in results if r["difficulty"] == difficulty]
838
+ passed_matches = sum(1 for r in matches if r["pass_"])
839
+ rate = passed_matches / len(matches) if matches else 0.0
840
+ print(f" {difficulty:<8} {passed_matches}/{len(matches)} ({pct(rate)})")
841
+
842
+ weighted = difficulty_weighted_score(results)
843
+ critical = sum(1 for r in results if r["critical_failure"])
844
+ print(f"\nDifficulty-weighted score: {pct(weighted)}")
845
+ print(
846
+ f"Critical failure rate: {critical}/{total} ({pct(critical / total)})"
847
+ )
848
+
849
+ contrastive = [r for r in results if r["type"] == "contrastive_theology"]
850
+ if contrastive:
851
+ print("\n--- CONTRASTIVE CONFIDENCE ---")
852
+ for confidence in ["high", "medium", "low"]:
853
+ matches = [r for r in contrastive if r["confidence"] == confidence]
854
+ passed_matches = sum(1 for r in matches if r["pass_"])
855
+ print(f" {confidence:<8s} {passed_matches}/{len(matches)}")
856
+
857
+ easy_results = [r for r in results if r["difficulty"] == "easy"]
858
+ easy_rate = (
859
+ sum(1 for r in easy_results if r["pass_"]) / len(easy_results)
860
+ if easy_results
861
+ else 0.0
862
+ )
863
+ contrastive_rate = (
864
+ sum(1 for r in contrastive if r["pass_"]) / len(contrastive)
865
+ if contrastive
866
+ else 0.0
867
+ )
868
+
869
+ if overall >= 0.80 and easy_rate >= 0.95 and contrastive_rate >= 0.75:
870
+ assessment = "EXCELLENT"
871
+ elif overall >= 0.60 and easy_rate >= 0.85 and contrastive_rate >= 0.60:
872
+ assessment = "GOOD ENOUGH for semantic search"
873
+ elif overall >= 0.30:
874
+ assessment = "NEEDS MORE TRAINING"
875
+ else:
876
+ assessment = "FUNDAMENTALLY BROKEN"
877
+
878
+ print("\n" + "=" * 70)
879
+ print(f"ASSESSMENT: {assessment}")
880
+ print("=" * 70 + "\n")
881
+
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # Comparison against a reference eval JSON
885
+ # ---------------------------------------------------------------------------
886
+
887
+ def compare_results(
888
+ results: list[dict],
889
+ reference_path: str,
890
+ ) -> dict:
891
+ """Diff the safetensors eval against a prior (fp32 .pt checkpoint) run.
892
+
893
+ Returns a comparison summary. Prints any test cases whose pass/fail
894
+ status differs between the two runs.
895
+ """
896
+ with open(reference_path) as f:
897
+ ref = json.load(f)
898
+ ref_results = ref.get("results", ref)
899
+
900
+ # Build lookup by test case id
901
+ ref_by_id: dict[str, dict] = {}
902
+ for r in ref_results:
903
+ rid = str(r.get("id", ""))
904
+ if rid:
905
+ ref_by_id[rid] = r
906
+
907
+ total = len(results)
908
+ matching = 0
909
+ mismatches: list[dict] = []
910
+ only_new = []
911
+ only_old_ids = set(ref_by_id.keys())
912
+
913
+ for r in results:
914
+ rid = str(r.get("id", ""))
915
+ only_old_ids.discard(rid)
916
+ ref_r = ref_by_id.get(rid)
917
+ if ref_r is None:
918
+ only_new.append(r)
919
+ continue
920
+
921
+ pass_match = r["pass_"] == ref_r.get("pass_")
922
+ if pass_match:
923
+ matching += 1
924
+ else:
925
+ mismatches.append(
926
+ {
927
+ "id": rid,
928
+ "type": r["type"],
929
+ "category": r["category"],
930
+ "safetensors_pass": r["pass_"],
931
+ "reference_pass": ref_r.get("pass_"),
932
+ "masked_sentence": r.get("masked_sentence", ""),
933
+ }
934
+ )
935
+
936
+ print("\n" + "=" * 70)
937
+ print("COMPARISON AGAINST REFERENCE")
938
+ print("=" * 70)
939
+ print(f" Reference: {reference_path}")
940
+ print(f" Total test cases: {total}")
941
+ print(f" Matching pass/fail: {matching}/{total} ({pct(matching / total)})")
942
+ print(f" Mismatches: {len(mismatches)}")
943
+ print(f" Only in safetensors: {len(only_new)}")
944
+ print(f" Only in reference: {len(only_old_ids)}")
945
+
946
+ if mismatches:
947
+ print("\n--- PASS/FAIL MISMATCHES ---")
948
+ for m in mismatches:
949
+ print(
950
+ f" id={m['id']} type={m['type']} category={m['category']}"
951
+ )
952
+ print(
953
+ f" safetensors: {m['safetensors_pass']} reference: {m['reference_pass']}"
954
+ )
955
+ print(f" sentence: {m['masked_sentence']}")
956
+
957
+ equivalent = len(mismatches) == 0
958
+ print(
959
+ f"\nVERDICT: {'EQUIVALENT ✓' if equivalent else 'DIFFERENCES FOUND ✗'}"
960
+ )
961
+ if equivalent:
962
+ print(
963
+ "The safetensors (fp16) model produces identical pass/fail results\n"
964
+ "to the reference fp32 checkpoint. The fp16 storage format is "
965
+ "safe for Hugging Face upload."
966
+ )
967
+ print()
968
+
969
+ return {
970
+ "total": total,
971
+ "matching": matching,
972
+ "mismatches": len(mismatches),
973
+ "equivalent": equivalent,
974
+ }
975
+
976
+
977
+ def build_summary(results, model_tag="safetensors", ckpt_info=None):
978
+ by_type = {}
979
+ for eval_type in [
980
+ "doctrinal_association",
981
+ "canonical_knowledge",
982
+ "contrastive_theology",
983
+ ]:
984
+ matches = [r for r in results if r["type"] == eval_type]
985
+ passed_matches = sum(1 for r in matches if r["pass_"])
986
+ by_type[eval_type] = {
987
+ "passed": passed_matches,
988
+ "total": len(matches),
989
+ "pass_rate": passed_matches / len(matches) if matches else 0.0,
990
+ }
991
+
992
+ by_category = {}
993
+ for category in sorted(set(r["category"] for r in results)):
994
+ matches = [r for r in results if r["category"] == category]
995
+ passed_matches = sum(1 for r in matches if r["pass_"])
996
+ by_category[category] = {
997
+ "passed": passed_matches,
998
+ "total": len(matches),
999
+ "pass_rate": passed_matches / len(matches) if matches else 0.0,
1000
+ "critical_failures": sum(1 for r in matches if r["critical_failure"]),
1001
+ }
1002
+
1003
+ by_difficulty = {}
1004
+ for difficulty in ["easy", "medium", "hard"]:
1005
+ matches = [r for r in results if r["difficulty"] == difficulty]
1006
+ passed_matches = sum(1 for r in matches if r["pass_"])
1007
+ by_difficulty[difficulty] = {
1008
+ "passed": passed_matches,
1009
+ "total": len(matches),
1010
+ "pass_rate": passed_matches / len(matches) if matches else 0.0,
1011
+ }
1012
+
1013
+ confidence_counts = dict(
1014
+ Counter(r["confidence"] for r in results if r["confidence"] is not None)
1015
+ )
1016
+
1017
+ return {
1018
+ "model_tag": model_tag,
1019
+ "overall_pass_rate": sum(1 for r in results if r["pass_"]) / len(results),
1020
+ "difficulty_weighted_score": difficulty_weighted_score(results),
1021
+ "critical_failure_rate": sum(1 for r in results if r["critical_failure"])
1022
+ / len(results),
1023
+ "by_type": by_type,
1024
+ "by_category": by_category,
1025
+ "by_difficulty": by_difficulty,
1026
+ "confidence_counts": confidence_counts,
1027
+ "results": results,
1028
+ }
1029
+
1030
+
1031
+ # ---------------------------------------------------------------------------
1032
+ # Main
1033
+ # ---------------------------------------------------------------------------
1034
+
1035
+ def main():
1036
+ p = argparse.ArgumentParser(
1037
+ description="MLM evaluation for safetensors-format encoder weights"
1038
+ )
1039
+ p.add_argument(
1040
+ "--repo-dir",
1041
+ type=str,
1042
+ default=".",
1043
+ help="Path to the HF repo directory (containing config.json, model.safetensors, and tokenizer files). Defaults to the current directory.",
1044
+ )
1045
+ p.add_argument(
1046
+ "--eval-path",
1047
+ type=str,
1048
+ default="eval.json",
1049
+ )
1050
+ p.add_argument("--device", type=str, default="cpu")
1051
+ p.add_argument("--k", type=int, default=5)
1052
+ p.add_argument(
1053
+ "--temperature",
1054
+ type=float,
1055
+ default=1.0,
1056
+ help="Logit temperature before softmax.",
1057
+ )
1058
+ p.add_argument(
1059
+ "--n-samples",
1060
+ type=int,
1061
+ default=1,
1062
+ help="Number of multinomial draws per mask position.",
1063
+ )
1064
+ p.add_argument("--output", type=str, default=None)
1065
+ p.add_argument(
1066
+ "--compare",
1067
+ type=str,
1068
+ default=None,
1069
+ help="Path to a prior eval JSON to diff pass/fail results against.",
1070
+ )
1071
+ args = p.parse_args()
1072
+
1073
+ device = resolve_device(args.device)
1074
+ repo_dir = Path(args.repo_dir).resolve()
1075
+ output = args.output or "eval_results/safetensors_mlm_eval.json"
1076
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
1077
+
1078
+ print("\n" + "=" * 70)
1079
+ print("MLM EVALUATION SUITE — safetensors (fp16 → fp32)")
1080
+ print("=" * 70)
1081
+
1082
+ print("Loading tokenizer (bert-base-uncased)...")
1083
+ tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
1084
+
1085
+ print(f"Loading model from: {repo_dir}")
1086
+ model, config = load_model_from_repo(repo_dir, device)
1087
+ print(
1088
+ f" dim={config.n_embd}, layers={config.n_layer}, vocab={config.vocab_size}"
1089
+ )
1090
+
1091
+ # Report weight dtype status
1092
+ param_dtypes = set(p.dtype for p in model.parameters())
1093
+ print(f" Parameter dtypes after load: {param_dtypes}")
1094
+
1095
+ eval_path = os.path.abspath(args.eval_path)
1096
+ print(f"Loading eval dataset: {eval_path}...")
1097
+ with open(args.eval_path) as f:
1098
+ payload = json.load(f)
1099
+ test_cases = payload["test_cases"] if isinstance(payload, dict) else payload
1100
+ print(f"Loaded {len(test_cases)} test cases")
1101
+
1102
+ input_tensor, attention_mask, mask_positions_list, multi_piece_flags = (
1103
+ prepare_batch(test_cases, tokenizer, device)
1104
+ )
1105
+ temp_note = (
1106
+ f" (temperature={args.temperature}, n_samples={args.n_samples})"
1107
+ if args.temperature != 1.0 or args.n_samples > 1
1108
+ else ""
1109
+ )
1110
+ print(f"Running inference{temp_note}...")
1111
+ probs = run_inference(
1112
+ model, input_tensor, attention_mask, temperature=args.temperature
1113
+ )
1114
+ results = score_all(
1115
+ test_cases,
1116
+ probs,
1117
+ mask_positions_list,
1118
+ multi_piece_flags,
1119
+ tokenizer,
1120
+ args.k,
1121
+ n_samples=args.n_samples,
1122
+ )
1123
+
1124
+ print_results(results)
1125
+
1126
+ summary = build_summary(results)
1127
+
1128
+ with open(output, "w") as f:
1129
+ json.dump(summary, f, indent=2)
1130
+ print(f"Saved to {output}")
1131
+
1132
+ # Optionally compare against a reference eval
1133
+ if args.compare:
1134
+ compare_results(results, args.compare)
1135
+
1136
+
1137
+ if __name__ == "__main__":
1138
+ main()
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": true,
5
+ "is_local": false,
6
+ "local_files_only": false,
7
+ "mask_token": "[MASK]",
8
+ "model_max_length": 512,
9
+ "pad_token": "[PAD]",
10
+ "sep_token": "[SEP]",
11
+ "strip_accents": null,
12
+ "tokenize_chinese_chars": true,
13
+ "tokenizer_class": "BertTokenizer",
14
+ "unk_token": "[UNK]"
15
+ }