simonbb 0.0.7__tar.gz → 0.0.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simonbb-0.0.7 → simonbb-0.0.8}/PKG-INFO +1 -7
- {simonbb-0.0.7 → simonbb-0.0.8}/pyproject.toml +1 -7
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/experience.py +2 -4
- simonbb-0.0.8/src/simonbb/cis433/math500.py +105 -0
- simonbb-0.0.8/src/simonbb/cis433/qwen3.py +639 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/utils.py +139 -0
- simonbb-0.0.7/src/simonbb/cis438/agentic.py → simonbb-0.0.8/src/simonbb/cis438/rag.py +28 -30
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/utils.py +213 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/LICENSE +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/README.md +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/__init__.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/__init__.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/actions.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/agent.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/bert.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/dqn.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/gpt.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/ignite.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/transformer.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis433/wrappers.py +0 -0
- {simonbb-0.0.7 → simonbb-0.0.8}/src/simonbb/cis438/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simonbb
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: A small package to facilitate teaching AI courses at business schools
|
|
5
5
|
Project-URL: Homepage, https://simonbb.com
|
|
6
6
|
Author-email: Huaxia Rui <huaxia.rui@simon.rochester.edu>
|
|
@@ -10,12 +10,6 @@ Classifier: Operating System :: OS Independent
|
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Requires-Python: >=3.9
|
|
12
12
|
Requires-Dist: gymnasium~=1.3.0
|
|
13
|
-
Requires-Dist: langchain-chroma~=1.1.0
|
|
14
|
-
Requires-Dist: langchain-community~=0.4.1
|
|
15
|
-
Requires-Dist: langchain-core~=1.2.23
|
|
16
|
-
Requires-Dist: langchain-huggingface~=1.2.1
|
|
17
|
-
Requires-Dist: langchain~=1.2.13
|
|
18
|
-
Requires-Dist: langgraph~=1.1.3
|
|
19
13
|
Requires-Dist: matplotlib~=3.10.8
|
|
20
14
|
Requires-Dist: numpy~=2.4.0
|
|
21
15
|
Requires-Dist: opencv-python~=5.0.0.93
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simonbb"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.8"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Huaxia Rui", email="huaxia.rui@simon.rochester.edu" },
|
|
10
10
|
]
|
|
@@ -23,12 +23,6 @@ dependencies = [
|
|
|
23
23
|
"torchvision~=0.24.1",
|
|
24
24
|
"numpy~=2.4.0",
|
|
25
25
|
"transformers~=4.57.3",
|
|
26
|
-
"langchain-core~=1.2.23",
|
|
27
|
-
"langchain~=1.2.13",
|
|
28
|
-
"langgraph~=1.1.3",
|
|
29
|
-
"langchain-community~=0.4.1",
|
|
30
|
-
"langchain-chroma~=1.1.0",
|
|
31
|
-
"langchain-huggingface~=1.2.1",
|
|
32
26
|
"sentence-transformers~=5.3.0",
|
|
33
27
|
"gymnasium~=1.3.0",
|
|
34
28
|
"pytorch-ignite~=0.5.4",
|
|
@@ -31,9 +31,7 @@ class ExperienceSource:
|
|
|
31
31
|
"""
|
|
32
32
|
Item = tt.Tuple[Experience, ...]
|
|
33
33
|
|
|
34
|
-
def __init__(self, env: gym.Env | tt.Collection[gym.Env], agent: BaseAgent,
|
|
35
|
-
steps_count: int = 2, steps_delta: int = 1,
|
|
36
|
-
env_seed: tt.Optional[int] = None):
|
|
34
|
+
def __init__(self, env: gym.Env | tt.Collection[gym.Env], agent: BaseAgent, steps_count: int = 2, steps_delta: int = 1, env_seed: tt.Optional[int] = None):
|
|
37
35
|
"""
|
|
38
36
|
Create simple experience source
|
|
39
37
|
:param env: environment or list of environments to be used
|
|
@@ -46,7 +44,7 @@ class ExperienceSource:
|
|
|
46
44
|
if isinstance(env, (list, tuple)):
|
|
47
45
|
self.pool = env
|
|
48
46
|
# do the check for the multiple copies passed
|
|
49
|
-
ids = set(id(e) for e in env)
|
|
47
|
+
ids = set(id(e) for e in env) # id() returns a unique, constant integer identifier for a specified object during its lifetime
|
|
50
48
|
if len(ids) < len(env):
|
|
51
49
|
raise ValueError("You passed single environment instance multiple times")
|
|
52
50
|
else:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from ..utils import grade_math_answer, eta_progress_message
|
|
9
|
+
|
|
10
|
+
RE_NUMBER = re.compile( r"-?(?:\d+/\d+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)" )
|
|
11
|
+
def get_last_boxed(text):
|
|
12
|
+
# Find the last occurrence of "\boxed"
|
|
13
|
+
boxed_start_idx = text.rfind(r"\boxed")
|
|
14
|
+
if boxed_start_idx == -1:
|
|
15
|
+
return None
|
|
16
|
+
# Get position after "\boxed"
|
|
17
|
+
current_idx = boxed_start_idx + len(r"\boxed")
|
|
18
|
+
# Skip any whitespace after "\boxed"
|
|
19
|
+
while current_idx < len(text) and text[current_idx].isspace():
|
|
20
|
+
current_idx += 1
|
|
21
|
+
# Expect an opening brace "{"
|
|
22
|
+
if current_idx >= len(text) or text[current_idx] != "{":
|
|
23
|
+
return None
|
|
24
|
+
# Parse the braces with nesting
|
|
25
|
+
current_idx += 1
|
|
26
|
+
brace_depth = 1
|
|
27
|
+
content_start_idx = current_idx
|
|
28
|
+
while current_idx < len(text) and brace_depth > 0:
|
|
29
|
+
char = text[current_idx]
|
|
30
|
+
if char == "{":
|
|
31
|
+
brace_depth += 1
|
|
32
|
+
elif char == "}":
|
|
33
|
+
brace_depth -= 1
|
|
34
|
+
current_idx += 1
|
|
35
|
+
# Account for unbalanced braces
|
|
36
|
+
if brace_depth != 0:
|
|
37
|
+
return None
|
|
38
|
+
# Extract content inside the outermost braces
|
|
39
|
+
return text[content_start_idx:current_idx-1]
|
|
40
|
+
|
|
41
|
+
def extract_final_candidate(text, fallback="number_then_full"):
|
|
42
|
+
# Default return value if nothing matches
|
|
43
|
+
result = ""
|
|
44
|
+
if text:
|
|
45
|
+
boxed = get_last_boxed(text.strip()) # Prefer the last boxed expression if present
|
|
46
|
+
if boxed:
|
|
47
|
+
result = boxed.strip().strip("$ ")
|
|
48
|
+
# If no boxed expression, try fallback
|
|
49
|
+
elif fallback in ("number_then_full", "number_only"):
|
|
50
|
+
m = RE_NUMBER.findall(text)
|
|
51
|
+
if m: # Use last number
|
|
52
|
+
result = m[-1]
|
|
53
|
+
elif fallback == "number_then_full":
|
|
54
|
+
# Else return full text if no number found
|
|
55
|
+
result = text
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
def render_prompt(prompt):
|
|
59
|
+
template = (
|
|
60
|
+
"You are a helpful math assistant.\n"
|
|
61
|
+
"Answer the question and write the final result on a new line as:\n"
|
|
62
|
+
"\\boxed{ANSWER}\n\n"
|
|
63
|
+
f"Question:\n{prompt}\n\nAnswer:"
|
|
64
|
+
)
|
|
65
|
+
return template
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def evaluate_math500_stream( model, tokenizer, device, generate_func, math_data, out_path=None, max_new_tokens=512, verbose=False ):
|
|
69
|
+
num_examples = len(math_data)
|
|
70
|
+
num_correct = 0
|
|
71
|
+
start_time = time.time()
|
|
72
|
+
with open(Path( out_path ), "w", encoding="utf-8") as f: # Save results for inspection
|
|
73
|
+
for i, row in enumerate(math_data, start=1):
|
|
74
|
+
prompt = render_prompt(row["problem"]) # 1. Apply prompt template
|
|
75
|
+
gen_text = generate_func( model=model, tokenizer=tokenizer, prompt=prompt, device=device, max_new_tokens=max_new_tokens, verbose=verbose )
|
|
76
|
+
extracted = extract_final_candidate( gen_text )
|
|
77
|
+
is_correct = grade_math_answer( extracted, row["answer"] )
|
|
78
|
+
num_correct += int(is_correct)
|
|
79
|
+
record = { # Record to be saved for inspection
|
|
80
|
+
"index": i,
|
|
81
|
+
"problem": row["problem"],
|
|
82
|
+
"gtruth_answer": row["answer"],
|
|
83
|
+
"generated_text": gen_text,
|
|
84
|
+
"extracted": extracted,
|
|
85
|
+
"correct": bool(is_correct),
|
|
86
|
+
}
|
|
87
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
88
|
+
|
|
89
|
+
progress_msg = eta_progress_message( processed=i, total=num_examples, start_time=start_time, show_eta=True, label="MATH-500" )
|
|
90
|
+
print(progress_msg, end="\r", flush=True)
|
|
91
|
+
if verbose: # Print responses during the generation process
|
|
92
|
+
print(
|
|
93
|
+
f"\n\n{'='*50}\n{progress_msg}\n"
|
|
94
|
+
f"{'='*50}\nExtracted: {extracted}\n"
|
|
95
|
+
f"Expected: {row['answer']}\n"
|
|
96
|
+
f"Correct so far: {num_correct}\n{'-'*50}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Print summary information
|
|
100
|
+
seconds_elapsed = time.time() - start_time
|
|
101
|
+
acc = num_correct / num_examples if num_examples else 0.0
|
|
102
|
+
print(f"\nAccuracy: {acc*100:.1f}% ({num_correct}/{num_examples})")
|
|
103
|
+
print(f"Total time: {seconds_elapsed/60:.1f} min")
|
|
104
|
+
print(f"Logs written to: {out_path}")
|
|
105
|
+
return num_correct, num_examples, acc
|
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This code is modified based on Sebastian Raschka's Implementation for personal use. No copyright claimed.
|
|
3
|
+
Original Code Repository: https://github.com/rasbt/reasoning-from-scratch
|
|
4
|
+
Original model download at
|
|
5
|
+
https://huggingface.co/rasbt/qwen3-from-scratch/resolve/main/qwen3-0.6B-base.pth
|
|
6
|
+
https://huggingface.co/rasbt/qwen3-from-scratch/resolve/main/qwen3-0.6B-reasoning.pth
|
|
7
|
+
https://huggingface.co/rasbt/qwen3-from-scratch/resolve/main/tokenizer-base.json
|
|
8
|
+
https://huggingface.co/rasbt/qwen3-from-scratch/resolve/main/tokenizer-reasoning.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from safetensors.torch import load_file
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn as nn
|
|
19
|
+
|
|
20
|
+
# 0.6 billion parameters
|
|
21
|
+
QWEN_CONFIG_06_B = {
|
|
22
|
+
"vocab_size": 151_936, # Vocabulary size
|
|
23
|
+
"context_length": 40_960, # Length originally used during training
|
|
24
|
+
"emb_dim": 1024, # Embedding dimension
|
|
25
|
+
"n_heads": 16, # Number of attention heads
|
|
26
|
+
"n_layers": 28, # Number of layers
|
|
27
|
+
"hidden_dim": 3072, # Size of intermediate dim in FeedForward
|
|
28
|
+
"head_dim": 128, # Size of the heads in GQA
|
|
29
|
+
"qk_norm": True, # Whether to normalize queries & keys in GQA
|
|
30
|
+
"n_kv_groups": 8, # Key-Value groups for GQA
|
|
31
|
+
"rope_base": 1_000_000.0, # The base in RoPE's "theta"
|
|
32
|
+
"dtype": torch.bfloat16, # Lower-precision dtype to reduce memory
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
QWEN3_CONFIG_1_7B = {
|
|
36
|
+
"vocab_size": 151_936,
|
|
37
|
+
"context_length": 40_960,
|
|
38
|
+
"emb_dim": 2048, # 2x larger than 0.6B model
|
|
39
|
+
"n_heads": 16,
|
|
40
|
+
"n_layers": 28,
|
|
41
|
+
"hidden_dim": 6144, # 2x larger than 0.6B model
|
|
42
|
+
"head_dim": 128,
|
|
43
|
+
"qk_norm": True,
|
|
44
|
+
"n_kv_groups": 8,
|
|
45
|
+
"rope_base": 1_000_000.0,
|
|
46
|
+
"dtype": torch.bfloat16,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# 4 billion parameters
|
|
50
|
+
QWEN3_CONFIG_4B = {
|
|
51
|
+
"vocab_size": 151_936,
|
|
52
|
+
"context_length": 40_960,
|
|
53
|
+
"emb_dim": 2560, # 25% larger than above
|
|
54
|
+
"n_heads": 32, # 2x larger than above
|
|
55
|
+
"n_layers": 36, # 29% larger than above
|
|
56
|
+
"hidden_dim": 9728, # ~3x larger than above
|
|
57
|
+
"head_dim": 128,
|
|
58
|
+
"qk_norm": True,
|
|
59
|
+
"n_kv_groups": 8,
|
|
60
|
+
"rope_base": 1_000_000.0,
|
|
61
|
+
"dtype": torch.bfloat16,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# 8 billion parameters
|
|
65
|
+
QWEN3_CONFIG_8B = {
|
|
66
|
+
"vocab_size": 151_936,
|
|
67
|
+
"context_length": 40_960,
|
|
68
|
+
"emb_dim": 4096, # 60% larger than above
|
|
69
|
+
"n_heads": 32,
|
|
70
|
+
"n_layers": 36, # 26% larger than above
|
|
71
|
+
"hidden_dim": 12288,
|
|
72
|
+
"head_dim": 128,
|
|
73
|
+
"qk_norm": True,
|
|
74
|
+
"n_kv_groups": 8,
|
|
75
|
+
"rope_base": 1_000_000.0,
|
|
76
|
+
"dtype": torch.bfloat16,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# 14 billion parameters
|
|
80
|
+
QWEN3_CONFIG_14B = {
|
|
81
|
+
"vocab_size": 151_936,
|
|
82
|
+
"context_length": 40_960,
|
|
83
|
+
"emb_dim": 5120, # 25% larger than above
|
|
84
|
+
"n_heads": 40, # 25% larger than above
|
|
85
|
+
"n_layers": 40, # 11% larger than above
|
|
86
|
+
"hidden_dim": 17408, # 42% larger than above
|
|
87
|
+
"head_dim": 128,
|
|
88
|
+
"qk_norm": True,
|
|
89
|
+
"n_kv_groups": 8,
|
|
90
|
+
"rope_base": 1_000_000.0,
|
|
91
|
+
"dtype": torch.bfloat16,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
QWEN3_CONFIG_32B = {
|
|
95
|
+
"vocab_size": 151_936,
|
|
96
|
+
"context_length": 40_960,
|
|
97
|
+
"emb_dim": 5120,
|
|
98
|
+
"n_heads": 64, # 60% larger than above
|
|
99
|
+
"n_layers": 64, # 60% larger than above
|
|
100
|
+
"hidden_dim": 25600, # 47% larger than above
|
|
101
|
+
"head_dim": 128,
|
|
102
|
+
"qk_norm": True,
|
|
103
|
+
"n_kv_groups": 8,
|
|
104
|
+
"rope_base": 1_000_000.0,
|
|
105
|
+
"dtype": torch.bfloat16,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class Qwen3Model(nn.Module):
|
|
109
|
+
def __init__(self, cfg):
|
|
110
|
+
super().__init__()
|
|
111
|
+
|
|
112
|
+
# Main model parameters
|
|
113
|
+
self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"], dtype=cfg["dtype"])
|
|
114
|
+
|
|
115
|
+
self.trf_blocks = nn.ModuleList( # ModuleList since Sequential can only accept one input, and we need `x, mask, cos, sin`
|
|
116
|
+
[TransformerBlock(cfg) for _ in range(cfg["n_layers"])]
|
|
117
|
+
)
|
|
118
|
+
self.final_norm = RMSNorm(cfg["emb_dim"])
|
|
119
|
+
self.out_head = nn.Linear(cfg["emb_dim"], cfg["vocab_size"], bias=False, dtype=cfg["dtype"])
|
|
120
|
+
|
|
121
|
+
# Reusable utilities
|
|
122
|
+
if cfg["head_dim"] is None:
|
|
123
|
+
head_dim = cfg["emb_dim"] // cfg["n_heads"]
|
|
124
|
+
else:
|
|
125
|
+
head_dim = cfg["head_dim"]
|
|
126
|
+
cos, sin = compute_rope_params(
|
|
127
|
+
head_dim=head_dim,
|
|
128
|
+
theta_base=cfg["rope_base"],
|
|
129
|
+
context_length=cfg["context_length"]
|
|
130
|
+
)
|
|
131
|
+
self.register_buffer("cos", cos, persistent=False)
|
|
132
|
+
self.register_buffer("sin", sin, persistent=False)
|
|
133
|
+
self.cfg = cfg
|
|
134
|
+
self.current_pos = 0 # Track current position in KV cache
|
|
135
|
+
|
|
136
|
+
def forward(self, in_idx, cache=None):
|
|
137
|
+
# Forward pass
|
|
138
|
+
tok_embeds = self.tok_emb(in_idx)
|
|
139
|
+
x = tok_embeds
|
|
140
|
+
|
|
141
|
+
num_tokens = x.shape[1]
|
|
142
|
+
if cache is not None:
|
|
143
|
+
pos_start = self.current_pos
|
|
144
|
+
pos_end = pos_start + num_tokens
|
|
145
|
+
self.current_pos = pos_end
|
|
146
|
+
mask = torch.triu(
|
|
147
|
+
torch.ones(pos_end, pos_end, device=x.device, dtype=torch.bool), diagonal=1
|
|
148
|
+
)[pos_start:pos_end, :pos_end]
|
|
149
|
+
else:
|
|
150
|
+
pos_start = 0 # Not strictly necessary but helps torch.compile
|
|
151
|
+
mask = torch.triu(
|
|
152
|
+
torch.ones(num_tokens, num_tokens, device=x.device, dtype=torch.bool), diagonal=1
|
|
153
|
+
)
|
|
154
|
+
# Prefill (no cache): mask starts as (num_tokens, num_tokens)
|
|
155
|
+
# Cached decoding: mask starts as (num_tokens, prev_k_number_tokens + num_tokens)
|
|
156
|
+
#
|
|
157
|
+
# We add two leading dimensions so the mask becomes
|
|
158
|
+
# (1, 1, num_tokens, num_tokens) during prefill and
|
|
159
|
+
# (1, 1, num_tokens, total_key_tokens) during cached decoding.
|
|
160
|
+
# These extra dimensions let PyTorch broadcast the same mask
|
|
161
|
+
# across all batches and attention heads when applying it to
|
|
162
|
+
# attn_scores of shape (batch, num_heads, num_tokens, total_key_tokens).
|
|
163
|
+
mask = mask[None, None, :, :] # broadcast mask
|
|
164
|
+
|
|
165
|
+
for i, block in enumerate(self.trf_blocks):
|
|
166
|
+
blk_cache = cache.get(i) if cache else None
|
|
167
|
+
x, new_blk_cache = block(x, mask, self.cos, self.sin,
|
|
168
|
+
start_pos=pos_start,
|
|
169
|
+
cache=blk_cache)
|
|
170
|
+
if cache is not None:
|
|
171
|
+
cache.update(i, new_blk_cache)
|
|
172
|
+
|
|
173
|
+
x = self.final_norm(x)
|
|
174
|
+
logits = self.out_head(x.to(self.cfg["dtype"]))
|
|
175
|
+
return logits
|
|
176
|
+
|
|
177
|
+
def reset_kv_cache(self):
|
|
178
|
+
self.current_pos = 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TransformerBlock(nn.Module):
|
|
182
|
+
def __init__(self, cfg):
|
|
183
|
+
super().__init__()
|
|
184
|
+
self.att = GroupedQueryAttention(
|
|
185
|
+
d_in=cfg["emb_dim"],
|
|
186
|
+
num_heads=cfg["n_heads"],
|
|
187
|
+
head_dim=cfg["head_dim"],
|
|
188
|
+
num_kv_groups=cfg["n_kv_groups"],
|
|
189
|
+
qk_norm=cfg["qk_norm"],
|
|
190
|
+
dtype=cfg["dtype"]
|
|
191
|
+
)
|
|
192
|
+
self.ff = FeedForward(cfg)
|
|
193
|
+
self.norm1 = RMSNorm(cfg["emb_dim"], eps=1e-6)
|
|
194
|
+
self.norm2 = RMSNorm(cfg["emb_dim"], eps=1e-6)
|
|
195
|
+
|
|
196
|
+
def forward(self, x, mask, cos, sin, start_pos=0, cache=None):
|
|
197
|
+
# Shortcut connection for attention block
|
|
198
|
+
shortcut = x
|
|
199
|
+
x = self.norm1(x)
|
|
200
|
+
x, next_cache = self.att(x, mask, cos, sin, start_pos=start_pos, cache=cache) # Shape [batch_size, num_tokens, emb_size]
|
|
201
|
+
x = x + shortcut # Add the original input back
|
|
202
|
+
|
|
203
|
+
# Shortcut connection for feed-forward block
|
|
204
|
+
shortcut = x
|
|
205
|
+
x = self.norm2(x)
|
|
206
|
+
x = self.ff(x)
|
|
207
|
+
x = x + shortcut # Add the original input back
|
|
208
|
+
|
|
209
|
+
return x, next_cache
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class FeedForward(nn.Module):
|
|
213
|
+
def __init__(self, cfg):
|
|
214
|
+
super().__init__()
|
|
215
|
+
self.fc1 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
|
|
216
|
+
self.fc2 = nn.Linear(cfg["emb_dim"], cfg["hidden_dim"], dtype=cfg["dtype"], bias=False)
|
|
217
|
+
self.fc3 = nn.Linear(cfg["hidden_dim"], cfg["emb_dim"], dtype=cfg["dtype"], bias=False)
|
|
218
|
+
|
|
219
|
+
def forward(self, x):
|
|
220
|
+
x_fc1 = self.fc1(x)
|
|
221
|
+
x_fc2 = self.fc2(x)
|
|
222
|
+
x = nn.functional.silu(x_fc1) * x_fc2
|
|
223
|
+
return self.fc3(x)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class GroupedQueryAttention(nn.Module):
|
|
227
|
+
def __init__(
|
|
228
|
+
self, d_in, num_heads, num_kv_groups, head_dim=None, qk_norm=False, dtype=None
|
|
229
|
+
):
|
|
230
|
+
super().__init__()
|
|
231
|
+
assert num_heads % num_kv_groups == 0, "num_heads must be divisible by num_kv_groups"
|
|
232
|
+
|
|
233
|
+
self.num_heads = num_heads
|
|
234
|
+
self.num_kv_groups = num_kv_groups
|
|
235
|
+
self.group_size = num_heads // num_kv_groups
|
|
236
|
+
|
|
237
|
+
if head_dim is None:
|
|
238
|
+
assert d_in % num_heads == 0, "`d_in` must be divisible by `num_heads` if `head_dim` is not set"
|
|
239
|
+
head_dim = d_in // num_heads
|
|
240
|
+
|
|
241
|
+
self.head_dim = head_dim
|
|
242
|
+
self.d_out = num_heads * head_dim
|
|
243
|
+
|
|
244
|
+
self.W_query = nn.Linear(d_in, self.d_out, bias=False, dtype=dtype)
|
|
245
|
+
self.W_key = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
|
|
246
|
+
self.W_value = nn.Linear(d_in, num_kv_groups * head_dim, bias=False, dtype=dtype)
|
|
247
|
+
|
|
248
|
+
self.out_proj = nn.Linear(self.d_out, d_in, bias=False, dtype=dtype)
|
|
249
|
+
|
|
250
|
+
if qk_norm:
|
|
251
|
+
self.q_norm = RMSNorm(head_dim, eps=1e-6)
|
|
252
|
+
self.k_norm = RMSNorm(head_dim, eps=1e-6)
|
|
253
|
+
else:
|
|
254
|
+
self.q_norm = self.k_norm = None
|
|
255
|
+
|
|
256
|
+
def forward(self, x, mask, cos, sin, start_pos=0, cache=None):
|
|
257
|
+
b, num_tokens, _ = x.shape
|
|
258
|
+
|
|
259
|
+
# Apply projections
|
|
260
|
+
queries = self.W_query(x) # (b, num_tokens, num_heads * head_dim)
|
|
261
|
+
keys = self.W_key(x) # (b, num_tokens, num_kv_groups * head_dim)
|
|
262
|
+
values = self.W_value(x) # (b, num_tokens, num_kv_groups * head_dim)
|
|
263
|
+
|
|
264
|
+
# Reshape to heads / kv-groups
|
|
265
|
+
queries = queries.view(b, num_tokens, self.num_heads, self.head_dim).transpose(1, 2)
|
|
266
|
+
keys_new = keys.view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
|
|
267
|
+
values_new = values.view(b, num_tokens, self.num_kv_groups, self.head_dim).transpose(1, 2)
|
|
268
|
+
|
|
269
|
+
# Optional normalization
|
|
270
|
+
if self.q_norm:
|
|
271
|
+
queries = self.q_norm(queries)
|
|
272
|
+
if self.k_norm:
|
|
273
|
+
keys_new = self.k_norm(keys_new)
|
|
274
|
+
|
|
275
|
+
# Apply RoPE
|
|
276
|
+
queries = apply_rope(queries, cos, sin, offset=start_pos)
|
|
277
|
+
keys_new = apply_rope(keys_new, cos, sin, offset=start_pos)
|
|
278
|
+
|
|
279
|
+
if cache is not None:
|
|
280
|
+
prev_k, prev_v = cache
|
|
281
|
+
keys = torch.cat([prev_k, keys_new], dim=2)
|
|
282
|
+
values = torch.cat([prev_v, values_new], dim=2)
|
|
283
|
+
else:
|
|
284
|
+
start_pos = 0 # reset RoPE
|
|
285
|
+
keys, values = keys_new, values_new
|
|
286
|
+
next_cache = (keys, values)
|
|
287
|
+
|
|
288
|
+
# Expand K and V to match number of heads
|
|
289
|
+
keys = keys.repeat_interleave(self.group_size, dim=1)
|
|
290
|
+
values = values.repeat_interleave(self.group_size, dim=1)
|
|
291
|
+
|
|
292
|
+
# Attention
|
|
293
|
+
attn_scores = queries @ keys.transpose(2, 3)
|
|
294
|
+
attn_scores = attn_scores.masked_fill(mask, -torch.inf)
|
|
295
|
+
attn_weights = torch.softmax(attn_scores / self.head_dim**0.5, dim=-1)
|
|
296
|
+
|
|
297
|
+
context = (attn_weights @ values).transpose(1, 2).reshape(b, num_tokens, self.d_out)
|
|
298
|
+
return self.out_proj(context), next_cache
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ==============================================================================
|
|
302
|
+
# RoPE implementation summary
|
|
303
|
+
#
|
|
304
|
+
#
|
|
305
|
+
# There are two common styles to implement RoPE, which are
|
|
306
|
+
# mathematically equivalent;
|
|
307
|
+
# they mainly differ in how the rotation matrix pairs dimensions.
|
|
308
|
+
#
|
|
309
|
+
# 1) Split-halves style (this repo, Hugging Face Transformers):
|
|
310
|
+
#
|
|
311
|
+
# For hidden dim d = 4 (example):
|
|
312
|
+
#
|
|
313
|
+
# [ x0 x1 | x2 x3 ]
|
|
314
|
+
# │ │ │ │
|
|
315
|
+
# ▼ ▼ ▼ ▼
|
|
316
|
+
# cos cos sin sin
|
|
317
|
+
#
|
|
318
|
+
# Rotation matrix:
|
|
319
|
+
#
|
|
320
|
+
# [ cosθ0 0 -sinθ0 0 ]
|
|
321
|
+
# [ 0 cosθ1 0 -sinθ1]
|
|
322
|
+
# [ sinθ0 0 cosθ0 0 ]
|
|
323
|
+
# [ 0 sinθ1 0 cosθ1]
|
|
324
|
+
#
|
|
325
|
+
# Here, the embedding dims are split into two halves and then
|
|
326
|
+
# each one is rotated in blocks.
|
|
327
|
+
#
|
|
328
|
+
#
|
|
329
|
+
# 2) Interleaved (even/odd) style (original paper, Llama repo):
|
|
330
|
+
#
|
|
331
|
+
# For hidden dim d = 4 (example):
|
|
332
|
+
#
|
|
333
|
+
# [ x0 x1 x2 x3 ]
|
|
334
|
+
# │ │ │ │
|
|
335
|
+
# ▼ ▼ ▼ ▼
|
|
336
|
+
# cos sin cos sin
|
|
337
|
+
#
|
|
338
|
+
# Rotation matrix:
|
|
339
|
+
#
|
|
340
|
+
# [ cosθ0 -sinθ0 0 0 ]
|
|
341
|
+
# [ sinθ0 cosθ0 0 0 ]
|
|
342
|
+
# [ 0 0 cosθ1 -sinθ1 ]
|
|
343
|
+
# [ 0 0 sinθ1 cosθ1 ]
|
|
344
|
+
#
|
|
345
|
+
#
|
|
346
|
+
# Here, embedding dims are interleaved as even/odd cosine/sine pairs.
|
|
347
|
+
#
|
|
348
|
+
# Both layouts encode the same relative positions; the only difference is how
|
|
349
|
+
# dimensions are paired.
|
|
350
|
+
# ==============================================================================
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def compute_rope_params(head_dim, theta_base=10_000, context_length=4096, dtype=torch.float32):
|
|
354
|
+
assert head_dim % 2 == 0, "Embedding dimension must be even"
|
|
355
|
+
|
|
356
|
+
# Compute the inverse frequencies
|
|
357
|
+
inv_freq = 1.0 / (theta_base ** (torch.arange(0, head_dim, 2, dtype=dtype)[: (head_dim // 2)].float() / head_dim))
|
|
358
|
+
|
|
359
|
+
# Generate position indices
|
|
360
|
+
positions = torch.arange(context_length, dtype=dtype)
|
|
361
|
+
|
|
362
|
+
# Compute the angles
|
|
363
|
+
angles = positions.unsqueeze(1) * inv_freq.unsqueeze(0) # Shape: (context_length, head_dim // 2)
|
|
364
|
+
|
|
365
|
+
# Expand angles to match the head_dim
|
|
366
|
+
angles = torch.cat([angles, angles], dim=1) # Shape: (context_length, head_dim)
|
|
367
|
+
|
|
368
|
+
# Precompute sine and cosine
|
|
369
|
+
cos = torch.cos(angles)
|
|
370
|
+
sin = torch.sin(angles)
|
|
371
|
+
|
|
372
|
+
return cos, sin
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def apply_rope(x, cos, sin, offset=0):
|
|
376
|
+
# x: (batch_size, num_heads, seq_len, head_dim)
|
|
377
|
+
batch_size, num_heads, seq_len, head_dim = x.shape
|
|
378
|
+
assert head_dim % 2 == 0, "Head dimension must be even"
|
|
379
|
+
|
|
380
|
+
# Split x into first half and second half
|
|
381
|
+
x1 = x[..., : head_dim // 2] # First half
|
|
382
|
+
x2 = x[..., head_dim // 2:] # Second half
|
|
383
|
+
|
|
384
|
+
# Adjust sin and cos shapes
|
|
385
|
+
cos = cos[offset:offset + seq_len, :].unsqueeze(0).unsqueeze(0) # Shape: (1, 1, seq_len, head_dim)
|
|
386
|
+
sin = sin[offset:offset + seq_len, :].unsqueeze(0).unsqueeze(0)
|
|
387
|
+
|
|
388
|
+
# Apply the rotary transformation
|
|
389
|
+
rotated = torch.cat((-x2, x1), dim=-1)
|
|
390
|
+
x_rotated = (x * cos) + (rotated * sin)
|
|
391
|
+
|
|
392
|
+
# It's ok to use lower-precision after applying cos and sin rotation
|
|
393
|
+
return x_rotated.to(dtype=x.dtype)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class RMSNorm(nn.Module):
|
|
397
|
+
def __init__(self, emb_dim, eps=1e-6, bias=False, qwen3_compatible=True):
|
|
398
|
+
super().__init__()
|
|
399
|
+
self.eps = eps
|
|
400
|
+
self.qwen3_compatible = qwen3_compatible
|
|
401
|
+
self.scale = nn.Parameter(torch.ones(emb_dim))
|
|
402
|
+
self.shift = nn.Parameter(torch.zeros(emb_dim)) if bias else None
|
|
403
|
+
|
|
404
|
+
def forward(self, x):
|
|
405
|
+
input_dtype = x.dtype
|
|
406
|
+
|
|
407
|
+
if self.qwen3_compatible:
|
|
408
|
+
x = x.to(torch.float32)
|
|
409
|
+
|
|
410
|
+
variance = x.pow(2).mean(dim=-1, keepdim=True)
|
|
411
|
+
norm_x = x * torch.rsqrt(variance + self.eps)
|
|
412
|
+
norm_x = norm_x * self.scale
|
|
413
|
+
|
|
414
|
+
if self.shift is not None:
|
|
415
|
+
norm_x = norm_x + self.shift
|
|
416
|
+
|
|
417
|
+
return norm_x.to(input_dtype)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class Qwen3Tokenizer:
|
|
421
|
+
_SPECIALS = [
|
|
422
|
+
"<|endoftext|>",
|
|
423
|
+
"<|im_start|>", "<|im_end|>",
|
|
424
|
+
"<|object_ref_start|>", "<|object_ref_end|>",
|
|
425
|
+
"<|box_start|>", "<|box_end|>",
|
|
426
|
+
"<|quad_start|>", "<|quad_end|>",
|
|
427
|
+
"<|vision_start|>", "<|vision_end|>",
|
|
428
|
+
"<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
|
|
429
|
+
]
|
|
430
|
+
_SPLIT_RE = re.compile(r"(<\|[^>]+?\|>)")
|
|
431
|
+
|
|
432
|
+
def __init__(self, tokenizer_file_path="tokenizer-base.json",
|
|
433
|
+
apply_chat_template=False,
|
|
434
|
+
add_generation_prompt=False,
|
|
435
|
+
add_thinking=False):
|
|
436
|
+
from tokenizers import Tokenizer
|
|
437
|
+
|
|
438
|
+
self.apply_chat_template = apply_chat_template
|
|
439
|
+
self.add_generation_prompt = add_generation_prompt
|
|
440
|
+
self.add_thinking = add_thinking
|
|
441
|
+
|
|
442
|
+
tok_path = Path(tokenizer_file_path)
|
|
443
|
+
if not tok_path.is_file():
|
|
444
|
+
raise FileNotFoundError(
|
|
445
|
+
f"Tokenizer file '{tok_path}' not found. Please ensure it's available."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
self._tok = Tokenizer.from_file(str(tok_path))
|
|
449
|
+
self._special_to_id = {t: self._tok.token_to_id(t) for t in self._SPECIALS}
|
|
450
|
+
|
|
451
|
+
self.pad_token = "<|endoftext|>"
|
|
452
|
+
self.pad_token_id = self._special_to_id.get(self.pad_token)
|
|
453
|
+
|
|
454
|
+
# Match HF behavior: chat model → <|im_end|>, base model → <|endoftext|>
|
|
455
|
+
fname = tok_path.name.lower()
|
|
456
|
+
if "base" in fname and "reasoning" not in fname:
|
|
457
|
+
self.eos_token = "<|endoftext|>"
|
|
458
|
+
else:
|
|
459
|
+
self.eos_token = "<|im_end|>"
|
|
460
|
+
self.eos_token_id = self._special_to_id.get(self.eos_token)
|
|
461
|
+
|
|
462
|
+
def encode(self, prompt, chat_wrapped=None):
|
|
463
|
+
if chat_wrapped is None:
|
|
464
|
+
chat_wrapped = self.apply_chat_template
|
|
465
|
+
|
|
466
|
+
stripped = prompt.strip()
|
|
467
|
+
if stripped in self._special_to_id and "\n" not in stripped:
|
|
468
|
+
return [self._special_to_id[stripped]]
|
|
469
|
+
|
|
470
|
+
if chat_wrapped:
|
|
471
|
+
prompt = self._wrap_chat(prompt)
|
|
472
|
+
|
|
473
|
+
ids = []
|
|
474
|
+
for part in filter(None, self._SPLIT_RE.split(prompt)):
|
|
475
|
+
if part in self._special_to_id:
|
|
476
|
+
ids.append(self._special_to_id[part])
|
|
477
|
+
else:
|
|
478
|
+
ids.extend(self._tok.encode(part).ids)
|
|
479
|
+
return ids
|
|
480
|
+
|
|
481
|
+
def decode(self, token_ids):
|
|
482
|
+
return self._tok.decode(token_ids, skip_special_tokens=False)
|
|
483
|
+
|
|
484
|
+
def _wrap_chat(self, user_msg):
|
|
485
|
+
s = f"<|im_start|>user\n{user_msg}<|im_end|>\n"
|
|
486
|
+
if self.add_generation_prompt:
|
|
487
|
+
s += "<|im_start|>assistant"
|
|
488
|
+
if self.add_thinking:
|
|
489
|
+
s += "\n" # insert no <think> tag, just a new line
|
|
490
|
+
else:
|
|
491
|
+
s += "\n<think>\n\n</think>\n\n"
|
|
492
|
+
return s
|
|
493
|
+
|
|
494
|
+
def load_hf_weights( local_dir ):
|
|
495
|
+
index_path = os.path.join(local_dir, "model.safetensors.index.json")
|
|
496
|
+
single_file_path = os.path.join(local_dir, "model.safetensors")
|
|
497
|
+
if os.path.exists(index_path): # Multi-shard model
|
|
498
|
+
with open(index_path, "r") as f:
|
|
499
|
+
index = json.load(f)
|
|
500
|
+
weights_dict = {}
|
|
501
|
+
for filename in set(index["weight_map"].values()):
|
|
502
|
+
shard_path = os.path.join(local_dir, filename)
|
|
503
|
+
shard = load_file(shard_path)
|
|
504
|
+
weights_dict.update(shard)
|
|
505
|
+
elif os.path.exists(single_file_path): # Single-shard model
|
|
506
|
+
weights_dict = load_file(single_file_path)
|
|
507
|
+
else:
|
|
508
|
+
raise FileNotFoundError("No model.safetensors or model.safetensors.index.json found.")
|
|
509
|
+
return weights_dict
|
|
510
|
+
|
|
511
|
+
def load_hf_weights_into_qwen(model, param_config, params):
|
|
512
|
+
"""
|
|
513
|
+
Only used in Appendix D for loading the other Qwen3 variants.
|
|
514
|
+
"""
|
|
515
|
+
def assign(left, right, tensor_name="unknown"):
|
|
516
|
+
if left.shape != right.shape:
|
|
517
|
+
raise ValueError(f"Shape mismatch in tensor '{tensor_name}'. Left: {left.shape}, Right: {right.shape}")
|
|
518
|
+
|
|
519
|
+
with torch.no_grad():
|
|
520
|
+
if isinstance(right, torch.Tensor):
|
|
521
|
+
left.copy_(right)
|
|
522
|
+
else:
|
|
523
|
+
left.copy_(torch.as_tensor(right, dtype=left.dtype, device=left.device))
|
|
524
|
+
|
|
525
|
+
return left
|
|
526
|
+
|
|
527
|
+
model.tok_emb.weight = assign(model.tok_emb.weight, params["model.embed_tokens.weight"], "model.embed_tokens.weight")
|
|
528
|
+
|
|
529
|
+
for l in range(param_config["n_layers"]): # noqa: E741
|
|
530
|
+
block = model.trf_blocks[l]
|
|
531
|
+
att = block.att
|
|
532
|
+
|
|
533
|
+
# Q, K, V projections
|
|
534
|
+
att.W_query.weight = assign(
|
|
535
|
+
att.W_query.weight,
|
|
536
|
+
params[f"model.layers.{l}.self_attn.q_proj.weight"],
|
|
537
|
+
f"model.layers.{l}.self_attn.q_proj.weight"
|
|
538
|
+
)
|
|
539
|
+
att.W_key.weight = assign(
|
|
540
|
+
att.W_key.weight,
|
|
541
|
+
params[f"model.layers.{l}.self_attn.k_proj.weight"],
|
|
542
|
+
f"model.layers.{l}.self_attn.k_proj.weight"
|
|
543
|
+
)
|
|
544
|
+
att.W_value.weight = assign(
|
|
545
|
+
att.W_value.weight,
|
|
546
|
+
params[f"model.layers.{l}.self_attn.v_proj.weight"],
|
|
547
|
+
f"model.layers.{l}.self_attn.v_proj.weight"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Output projection
|
|
551
|
+
att.out_proj.weight = assign(
|
|
552
|
+
att.out_proj.weight,
|
|
553
|
+
params[f"model.layers.{l}.self_attn.o_proj.weight"],
|
|
554
|
+
f"model.layers.{l}.self_attn.o_proj.weight"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# QK norms
|
|
558
|
+
if hasattr(att, "q_norm") and att.q_norm is not None:
|
|
559
|
+
att.q_norm.scale = assign(
|
|
560
|
+
att.q_norm.scale,
|
|
561
|
+
params[f"model.layers.{l}.self_attn.q_norm.weight"],
|
|
562
|
+
f"model.layers.{l}.self_attn.q_norm.weight"
|
|
563
|
+
)
|
|
564
|
+
if hasattr(att, "k_norm") and att.k_norm is not None:
|
|
565
|
+
att.k_norm.scale = assign(
|
|
566
|
+
att.k_norm.scale,
|
|
567
|
+
params[f"model.layers.{l}.self_attn.k_norm.weight"],
|
|
568
|
+
f"model.layers.{l}.self_attn.k_norm.weight"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Attention layernorm
|
|
572
|
+
block.norm1.scale = assign(
|
|
573
|
+
block.norm1.scale,
|
|
574
|
+
params[f"model.layers.{l}.input_layernorm.weight"],
|
|
575
|
+
f"model.layers.{l}.input_layernorm.weight"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Feedforward weights
|
|
579
|
+
if "num_experts" in param_config:
|
|
580
|
+
# Load router (gating) weights
|
|
581
|
+
block.ff.gate.weight = assign(
|
|
582
|
+
block.ff.gate.weight,
|
|
583
|
+
params[f"model.layers.{l}.mlp.gate.weight"],
|
|
584
|
+
f"model.layers.{l}.mlp.gate.weight"
|
|
585
|
+
)
|
|
586
|
+
# Load expert weights
|
|
587
|
+
for e in range(param_config["num_experts"]):
|
|
588
|
+
prefix = f"model.layers.{l}.mlp.experts.{e}"
|
|
589
|
+
block.ff.fc1[e].weight = assign(
|
|
590
|
+
block.ff.fc1[e].weight,
|
|
591
|
+
params[f"{prefix}.gate_proj.weight"],
|
|
592
|
+
f"{prefix}.gate_proj.weight"
|
|
593
|
+
)
|
|
594
|
+
block.ff.fc2[e].weight = assign(
|
|
595
|
+
block.ff.fc2[e].weight,
|
|
596
|
+
params[f"{prefix}.up_proj.weight"],
|
|
597
|
+
f"{prefix}.up_proj.weight"
|
|
598
|
+
)
|
|
599
|
+
block.ff.fc3[e].weight = assign(
|
|
600
|
+
block.ff.fc3[e].weight,
|
|
601
|
+
params[f"{prefix}.down_proj.weight"],
|
|
602
|
+
f"{prefix}.down_proj.weight"
|
|
603
|
+
)
|
|
604
|
+
# After assigning weights, move the expert layers from meta to CPU
|
|
605
|
+
block.ff.fc1[e] = block.ff.fc1[e].to("cpu")
|
|
606
|
+
block.ff.fc2[e] = block.ff.fc2[e].to("cpu")
|
|
607
|
+
block.ff.fc3[e] = block.ff.fc3[e].to("cpu")
|
|
608
|
+
|
|
609
|
+
else:
|
|
610
|
+
block.ff.fc1.weight = assign(
|
|
611
|
+
block.ff.fc1.weight,
|
|
612
|
+
params[f"model.layers.{l}.mlp.gate_proj.weight"],
|
|
613
|
+
f"model.layers.{l}.mlp.gate_proj.weight"
|
|
614
|
+
)
|
|
615
|
+
block.ff.fc2.weight = assign(
|
|
616
|
+
block.ff.fc2.weight,
|
|
617
|
+
params[f"model.layers.{l}.mlp.up_proj.weight"],
|
|
618
|
+
f"model.layers.{l}.mlp.up_proj.weight"
|
|
619
|
+
)
|
|
620
|
+
block.ff.fc3.weight = assign(
|
|
621
|
+
block.ff.fc3.weight,
|
|
622
|
+
params[f"model.layers.{l}.mlp.down_proj.weight"],
|
|
623
|
+
f"model.layers.{l}.mlp.down_proj.weight"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
block.norm2.scale = assign(
|
|
627
|
+
block.norm2.scale,
|
|
628
|
+
params[f"model.layers.{l}.post_attention_layernorm.weight"],
|
|
629
|
+
f"model.layers.{l}.post_attention_layernorm.weight"
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Final normalization and output head
|
|
633
|
+
model.final_norm.scale = assign(model.final_norm.scale, params["model.norm.weight"], "model.norm.weight")
|
|
634
|
+
|
|
635
|
+
if "lm_head.weight" in params:
|
|
636
|
+
model.out_head.weight = assign(model.out_head.weight, params["lm_head.weight"], "lm_head.weight")
|
|
637
|
+
else:
|
|
638
|
+
model.out_head.weight = model.tok_emb.weight
|
|
639
|
+
print("Model uses weight tying.")
|
|
@@ -14,6 +14,37 @@ import torch.nn as nn
|
|
|
14
14
|
|
|
15
15
|
#%%
|
|
16
16
|
|
|
17
|
+
def top_p_filter(probas, top_p):
|
|
18
|
+
if top_p is None or top_p >= 1.0:
|
|
19
|
+
return probas
|
|
20
|
+
sorted_probas, sorted_idx = torch.sort(probas, dim=1, descending=True)
|
|
21
|
+
cumprobas = torch.cumsum(sorted_probas, dim=1)
|
|
22
|
+
cumprobas_before_atom = cumprobas - sorted_probas
|
|
23
|
+
keep = cumprobas_before_atom < top_p
|
|
24
|
+
keep[:, 0] = True # Always keep at least one token (fallback for very small/non-positive top_p)
|
|
25
|
+
kept_sorted = torch.where( keep, sorted_probas, torch.zeros_like(sorted_probas) )
|
|
26
|
+
filtered = torch.zeros_like(probas).scatter(1, sorted_idx, kept_sorted) # Map back to original order
|
|
27
|
+
denom = torch.sum(filtered, dim=1, keepdim=True).clamp_min(1e-12) # Renormalize to sum to 1
|
|
28
|
+
return filtered / denom
|
|
29
|
+
|
|
30
|
+
class KVCache:
|
|
31
|
+
def __init__(self, n_layers):
|
|
32
|
+
self.cache = [None] * n_layers
|
|
33
|
+
|
|
34
|
+
def get(self, layer_idx):
|
|
35
|
+
return self.cache[layer_idx]
|
|
36
|
+
|
|
37
|
+
def update(self, layer_idx, value):
|
|
38
|
+
self.cache[layer_idx] = value
|
|
39
|
+
|
|
40
|
+
def get_all(self):
|
|
41
|
+
return self.cache
|
|
42
|
+
|
|
43
|
+
def reset(self):
|
|
44
|
+
for i in range(len(self.cache)):
|
|
45
|
+
self.cache[i] = None
|
|
46
|
+
|
|
47
|
+
|
|
17
48
|
class ModelCheckpoint:
|
|
18
49
|
def __init__(self, filepath, monitor="val_loss", save_best_only=True, save_optimizer_state=False):
|
|
19
50
|
self.filepath = filepath
|
|
@@ -69,6 +100,25 @@ def stats_per_channel(loader):
|
|
|
69
100
|
norm_std = total_stds / len(loader.dataset) # (1, n_channels)
|
|
70
101
|
return norm_mean.squeeze(), norm_std.squeeze() # without squeezing, runtime error: not all boolean value of tensor with more than one value is ambiguous
|
|
71
102
|
|
|
103
|
+
import warnings
|
|
104
|
+
def generate_stats(output_token_ids, tokenizer, start_time, end_time):
|
|
105
|
+
total_time = end_time - start_time
|
|
106
|
+
print(f"\n\nTime: {total_time:.2f} sec")
|
|
107
|
+
print(f"{int(output_token_ids.numel() / total_time)} tokens/sec")
|
|
108
|
+
for name, backend in (("CUDA", getattr(torch, "cuda", None)), ("XPU", getattr(torch, "xpu", None))):
|
|
109
|
+
if backend is not None and backend.is_available():
|
|
110
|
+
# Check whether we are actually using this backend
|
|
111
|
+
device_type = output_token_ids.device.type
|
|
112
|
+
if device_type != name.lower():
|
|
113
|
+
warnings.warn( f"{name} is available but tensors are on {device_type}. Memory stats may be 0." )
|
|
114
|
+
# Synchronize if supported (important for async backends)
|
|
115
|
+
if hasattr(backend, "synchronize"):
|
|
116
|
+
backend.synchronize()
|
|
117
|
+
max_mem_bytes = backend.max_memory_allocated()
|
|
118
|
+
max_mem_gb = max_mem_bytes / (1024 ** 3)
|
|
119
|
+
print(f"Max {name} memory allocated: {max_mem_gb:.2f} GB")
|
|
120
|
+
backend.reset_peak_memory_stats()
|
|
121
|
+
|
|
72
122
|
#%% Train Loop: tested on classification, regression, image segmentation, translation, gpt2
|
|
73
123
|
|
|
74
124
|
def train( model, train_dataloader, val_dataloader, optimizer, scheduler=None, clip_grad=None, loss_fn=nn.CrossEntropyLoss(), callbacks=None, device=None, evaluation=True, n_epochs=1, n_batch_per_report=5, accuracy_fn=None, lr_history=None ):
|
|
@@ -311,6 +361,95 @@ def evaluate(model, loader, device, loss_fn=nn.CrossEntropyLoss(), accuracy_fn=N
|
|
|
311
361
|
val_accuracy = val_accuracy.item() # extract the value of a single-item tensor
|
|
312
362
|
return {'loss':val_loss, 'accuracy':val_accuracy}
|
|
313
363
|
|
|
364
|
+
#%%
|
|
365
|
+
|
|
366
|
+
@torch.inference_mode()
|
|
367
|
+
def generate_text_basic_stream( model, token_ids, max_new_tokens, eos_token_id=None, kv_cache = True ):
|
|
368
|
+
model.eval()
|
|
369
|
+
if kv_cache:
|
|
370
|
+
cache = KVCache(n_layers=model.cfg["n_layers"])
|
|
371
|
+
model.reset_kv_cache()
|
|
372
|
+
out = model(token_ids, cache=cache)[:, -1]
|
|
373
|
+
else:
|
|
374
|
+
out = model(token_ids)[:, -1]
|
|
375
|
+
|
|
376
|
+
for _ in range(max_new_tokens):
|
|
377
|
+
next_token = torch.argmax(out, dim=-1, keepdim=True)
|
|
378
|
+
if (eos_token_id is not None and torch.all(next_token == eos_token_id)):
|
|
379
|
+
break
|
|
380
|
+
yield next_token
|
|
381
|
+
if kv_cache:
|
|
382
|
+
out = model(next_token, cache=cache)[:, -1]
|
|
383
|
+
else:
|
|
384
|
+
token_ids = torch.cat([token_ids, next_token], dim=1)
|
|
385
|
+
out = model(token_ids)[:, -1]
|
|
386
|
+
|
|
387
|
+
def generate_text_stream_concat( model, tokenizer, prompt, device, max_new_tokens, verbose=False ):
|
|
388
|
+
input_ids = torch.tensor( tokenizer.encode(prompt), device=device ).unsqueeze(0)
|
|
389
|
+
generated_ids = []
|
|
390
|
+
for token in generate_text_basic_stream( model=model, token_ids=input_ids, max_new_tokens=max_new_tokens, eos_token_id=tokenizer.eos_token_id ):
|
|
391
|
+
next_token_id = token.squeeze(0)
|
|
392
|
+
generated_ids.append(next_token_id.item())
|
|
393
|
+
if verbose:
|
|
394
|
+
print( tokenizer.decode(next_token_id.tolist()), end="", flush=True )
|
|
395
|
+
return tokenizer.decode(generated_ids)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def generate_text_stream_concat_flex( model, tokenizer, prompt, device, max_new_tokens, verbose=False, generate_func=None, **generate_kwargs ):
|
|
399
|
+
if generate_func is None:
|
|
400
|
+
generate_func = generate_text_basic_stream_cache
|
|
401
|
+
input_ids = torch.tensor( tokenizer.encode(prompt), device=device ).unsqueeze(0)
|
|
402
|
+
generated_ids = []
|
|
403
|
+
for token in generate_func(
|
|
404
|
+
model=model,
|
|
405
|
+
token_ids=input_ids,
|
|
406
|
+
max_new_tokens=max_new_tokens,
|
|
407
|
+
eos_token_id=tokenizer.eos_token_id,
|
|
408
|
+
**generate_kwargs,
|
|
409
|
+
):
|
|
410
|
+
next_token_id = token.squeeze(0)
|
|
411
|
+
generated_ids.append(next_token_id.item())
|
|
412
|
+
if verbose:
|
|
413
|
+
print( tokenizer.decode(next_token_id.tolist()), end="", flush=True )
|
|
414
|
+
return tokenizer.decode(generated_ids)
|
|
415
|
+
|
|
416
|
+
@torch.inference_mode()
|
|
417
|
+
def generate_text_basic_stream_cache( model, token_ids, max_new_tokens, eos_token_id=None ):
|
|
418
|
+
model.eval()
|
|
419
|
+
cache = KVCache(n_layers=model.cfg["n_layers"])
|
|
420
|
+
model.reset_kv_cache()
|
|
421
|
+
out = model(token_ids, cache=cache)[:, -1]
|
|
422
|
+
for _ in range(max_new_tokens):
|
|
423
|
+
next_token = torch.argmax(out, dim=-1, keepdim=True)
|
|
424
|
+
if (eos_token_id is not None and torch.all(next_token == eos_token_id)):
|
|
425
|
+
break
|
|
426
|
+
yield next_token
|
|
427
|
+
out = model(next_token, cache=cache)[:, -1]
|
|
428
|
+
|
|
429
|
+
@torch.inference_mode()
|
|
430
|
+
def generate_text_top_p_stream_cache( model, token_ids, max_new_tokens, eos_token_id=None, temperature=0., top_p=None ):
|
|
431
|
+
model.eval()
|
|
432
|
+
cache = KVCache(n_layers=model.cfg["n_layers"])
|
|
433
|
+
model.reset_kv_cache()
|
|
434
|
+
out = model(token_ids, cache=cache)[:, -1]
|
|
435
|
+
for _ in range(max_new_tokens):
|
|
436
|
+
orig_device = token_ids.device
|
|
437
|
+
if temperature <= 0:
|
|
438
|
+
raise ValueError("Temperature must be positive")
|
|
439
|
+
elif temperature is None or temperature == 0.0:
|
|
440
|
+
next_token = torch.argmax(out, dim=-1, keepdim=True)
|
|
441
|
+
else:
|
|
442
|
+
logits = out / temperature
|
|
443
|
+
probas = torch.softmax(logits, dim=-1)
|
|
444
|
+
probas = top_p_filter(probas, top_p)
|
|
445
|
+
next_token = torch.multinomial(probas.cpu(), num_samples=1)
|
|
446
|
+
next_token = next_token.to(orig_device)
|
|
447
|
+
if (eos_token_id is not None
|
|
448
|
+
and torch.all(next_token == eos_token_id)):
|
|
449
|
+
break
|
|
450
|
+
yield next_token
|
|
451
|
+
out = model(next_token, cache=cache)[:, -1]
|
|
452
|
+
|
|
314
453
|
#%% Sanity Check: run from the parent folder of simonbb as "python -m simonbb.cis433.utils" since we used relative import
|
|
315
454
|
|
|
316
455
|
if __name__ == "__main__":
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
from typing import Annotated, Literal, TypedDict, get_args
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from functools import partial
|
|
9
10
|
|
|
@@ -31,42 +32,39 @@ embedding_model = HuggingFaceEmbeddings(
|
|
|
31
32
|
|
|
32
33
|
#%%
|
|
33
34
|
|
|
34
|
-
def load_documents(path):
|
|
35
|
+
def load_documents(path: Path):
|
|
35
36
|
"""Load documents from a file or directory."""
|
|
36
37
|
|
|
38
|
+
valid_extensions = {'.pdf', '.html', '.ejs'}
|
|
39
|
+
|
|
37
40
|
docs = []
|
|
38
41
|
success_cnt = 0
|
|
39
42
|
page_cnt = 0
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
print(filename)
|
|
61
|
-
ext = filename.lower()
|
|
62
|
-
if ext.endswith('.pdf'):
|
|
63
|
-
documents = PyPDFLoader(file).load()
|
|
64
|
-
elif ext.endswith(('.html', '.ejs')):
|
|
65
|
-
documents = UnstructuredHTMLLoader(file).load()
|
|
43
|
+
|
|
44
|
+
if path.exists():
|
|
45
|
+
if path.is_dir():
|
|
46
|
+
for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
|
|
47
|
+
print(file.name)
|
|
48
|
+
if file.suffix == '.pdf':
|
|
49
|
+
documents = PyPDFLoader(file).load()
|
|
50
|
+
elif file.suffix in {'.html', '.ejs'}:
|
|
51
|
+
documents = UnstructuredHTMLLoader(file).load()
|
|
52
|
+
else:
|
|
53
|
+
continue
|
|
54
|
+
success_cnt += 1
|
|
55
|
+
page_cnt += len(documents)
|
|
56
|
+
docs.extend(documents)
|
|
57
|
+
else:
|
|
58
|
+
print(path.name)
|
|
59
|
+
if path.suffix == '.pdf':
|
|
60
|
+
documents = PyPDFLoader(path).load()
|
|
61
|
+
elif path.suffix in {'.html', '.ejs'}:
|
|
62
|
+
documents = UnstructuredHTMLLoader(path).load()
|
|
66
63
|
else:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
print(f"Unsupported file type: {path}")
|
|
65
|
+
return None, 0, 0
|
|
66
|
+
success_cnt = 1
|
|
67
|
+
page_cnt = len(documents)
|
|
70
68
|
docs.extend(documents)
|
|
71
69
|
else:
|
|
72
70
|
print(f"Error: '{path}' is not a valid file or directory")
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import time
|
|
2
2
|
import re
|
|
3
|
+
from IPython.display import display, Math
|
|
3
4
|
import collections
|
|
4
5
|
import sys
|
|
5
6
|
import pickle
|
|
6
7
|
import json
|
|
7
8
|
from collections import Counter
|
|
8
9
|
import urllib.request
|
|
10
|
+
|
|
9
11
|
import numpy as np
|
|
10
12
|
import torch
|
|
11
13
|
import torch.nn as nn
|
|
12
14
|
|
|
15
|
+
|
|
13
16
|
class WeightedMSELoss(nn.Module):
|
|
14
17
|
def __init__(self, size_average=True):
|
|
15
18
|
super(WeightedMSELoss, self).__init__()
|
|
@@ -268,3 +271,213 @@ def query_ollama(prompt, model="llama3", url="http://localhost:11434/api/chat"):
|
|
|
268
271
|
response_data += response_json["message"]["content"]
|
|
269
272
|
|
|
270
273
|
return response_data
|
|
274
|
+
|
|
275
|
+
def display_latex( prose_and_equations ):
|
|
276
|
+
pattern = re.compile( r"\$\$(.*?)\$\$|(?<!\$)\$(?!\$)(.*?)(?<!\$)\$(?!\$)", flags=re.DOTALL )
|
|
277
|
+
position = 0
|
|
278
|
+
for match in pattern.finditer( prose_and_equations ):
|
|
279
|
+
print( prose_and_equations[position:match.start()], end="") # Preserve and print all prose exactly
|
|
280
|
+
equation = match.group(1) if match.group(1) is not None else match.group(2)
|
|
281
|
+
display(Math(equation.strip()))
|
|
282
|
+
position = match.end()
|
|
283
|
+
print(prose_and_equations[position:], end="") # Preserve text following the final equation
|
|
284
|
+
|
|
285
|
+
def eta_progress_message( processed, total, start_time, show_eta=False, label="Progress" ):
|
|
286
|
+
progress = f"{label}: {processed}/{total}"
|
|
287
|
+
pad_width = len(f"{label}: {total}/{total} | ETA: 00h 00m 00s")
|
|
288
|
+
if not show_eta or processed <= 0:
|
|
289
|
+
return progress.ljust(pad_width)
|
|
290
|
+
elapsed = time.time() - start_time
|
|
291
|
+
if elapsed <= 0:
|
|
292
|
+
return progress.ljust(pad_width)
|
|
293
|
+
remaining = max(total - processed, 0)
|
|
294
|
+
if processed:
|
|
295
|
+
avg_time = elapsed / processed
|
|
296
|
+
eta_seconds = avg_time * remaining
|
|
297
|
+
else:
|
|
298
|
+
eta_seconds = 0
|
|
299
|
+
eta_seconds = max(int(round(eta_seconds)), 0)
|
|
300
|
+
minutes, rem_seconds = divmod(eta_seconds, 60)
|
|
301
|
+
hours, minutes = divmod(minutes, 60)
|
|
302
|
+
if hours:
|
|
303
|
+
eta = f"{hours}h {minutes:02d}m {rem_seconds:02d}s"
|
|
304
|
+
elif minutes:
|
|
305
|
+
eta = f"{minutes:02d}m {rem_seconds:02d}s"
|
|
306
|
+
else:
|
|
307
|
+
eta = f"{rem_seconds:02d}s"
|
|
308
|
+
message = f"{progress} | ETA: {eta}"
|
|
309
|
+
return message.ljust(pad_width)
|
|
310
|
+
|
|
311
|
+
#%% math verifier
|
|
312
|
+
|
|
313
|
+
from sympy import simplify
|
|
314
|
+
from sympy.parsing import sympy_parser
|
|
315
|
+
from sympy.core.sympify import SympifyError
|
|
316
|
+
from tokenize import TokenError
|
|
317
|
+
from sympy.polys.polyerrors import PolynomialError
|
|
318
|
+
|
|
319
|
+
def grade_math_answer(pred_text, gt_text):
|
|
320
|
+
result = False # Default outcome if checks fail
|
|
321
|
+
# Only continue if both inputs are non-empty strings
|
|
322
|
+
if pred_text is not None and gt_text is not None:
|
|
323
|
+
gt_parts = _split_into_parts( _normalize_text(gt_text) ) # Break ground truth into comparable parts
|
|
324
|
+
pred_parts = _split_into_parts( _normalize_text(pred_text) ) # Break prediction into comparable parts
|
|
325
|
+
# Ensure both sides have same number of valid parts
|
|
326
|
+
if (gt_parts and pred_parts
|
|
327
|
+
and len(gt_parts) == len(pred_parts)):
|
|
328
|
+
result = all(
|
|
329
|
+
equality_check(gt, pred)
|
|
330
|
+
for gt, pred in zip(gt_parts, pred_parts)
|
|
331
|
+
) # Check each part for mathematical equivalence
|
|
332
|
+
return result # True only if all checks passed
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def equality_check(expr_gtruth, expr_pred):
|
|
336
|
+
# First, check if the two expressions are exactly the same string
|
|
337
|
+
if expr_gtruth == expr_pred:
|
|
338
|
+
return True
|
|
339
|
+
# Parse both expressions into SymPy objects (returns None if parsing fails)
|
|
340
|
+
gtruth, pred = _sympy_parser(expr_gtruth), _sympy_parser(expr_pred)
|
|
341
|
+
# If both expressions were parsed successfully, try symbolic comparison
|
|
342
|
+
if gtruth is not None and pred is not None:
|
|
343
|
+
try:
|
|
344
|
+
# If the difference is 0, they are equivalent
|
|
345
|
+
return simplify(gtruth - pred) == 0
|
|
346
|
+
except (SympifyError, TypeError):
|
|
347
|
+
pass
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
def _split_into_parts(text):
|
|
351
|
+
result = [text]
|
|
352
|
+
if text:
|
|
353
|
+
# Check if text looks like a tuple or list, e.g. "(a, b)" or "[a, b]"
|
|
354
|
+
if (
|
|
355
|
+
len(text) >= 2
|
|
356
|
+
and text[0] in "([" and text[-1] in ")]"
|
|
357
|
+
and "," in text[1:-1]
|
|
358
|
+
):
|
|
359
|
+
# Split on commas inside brackets and strip whitespace
|
|
360
|
+
items = [p.strip() for p in text[1:-1].split(",")]
|
|
361
|
+
if all(items):
|
|
362
|
+
result = items
|
|
363
|
+
else:
|
|
364
|
+
# If text is empty, return an empty list
|
|
365
|
+
result = []
|
|
366
|
+
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _sympy_parser(expr):
|
|
371
|
+
# To avoid crashing on long garbage responses
|
|
372
|
+
# that some badly trained models (chapter 6) may emit
|
|
373
|
+
if expr is None or len(expr) > 2000:
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
return sympy_parser.parse_expr(
|
|
377
|
+
expr,
|
|
378
|
+
transformations=(
|
|
379
|
+
# Standard transformations like handling parentheses
|
|
380
|
+
*sympy_parser.standard_transformations,
|
|
381
|
+
# Allow omitted multiplication symbols (e.g., "2x" -> 2*x")
|
|
382
|
+
sympy_parser.implicit_multiplication_application,
|
|
383
|
+
),
|
|
384
|
+
# Evaluate during parsing so simple constants simplify (e.g., 2+3 -> 5)
|
|
385
|
+
evaluate=True,
|
|
386
|
+
)
|
|
387
|
+
except (SympifyError, SyntaxError, TypeError, AttributeError,
|
|
388
|
+
IndexError, TokenError, ValueError, PolynomialError):
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
LATEX_FIXES = [ # Latex formatting to be replaced
|
|
392
|
+
(r"\\left\s*", ""),
|
|
393
|
+
(r"\\right\s*", ""),
|
|
394
|
+
(r"\\,|\\!|\\;|\\:", ""),
|
|
395
|
+
(r"\\cdot", "*"),
|
|
396
|
+
(r"\u00B7|\u00D7", "*"),
|
|
397
|
+
(r"\\\^\\circ", ""),
|
|
398
|
+
(r"\\dfrac", r"\\frac"),
|
|
399
|
+
(r"\\tfrac", r"\\frac"),
|
|
400
|
+
(r"°", ""),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
RE_SPECIAL = re.compile(r"<\|[^>]+?\|>") # strip chat special tokens like <|assistant|>
|
|
404
|
+
|
|
405
|
+
def _normalize_text(text):
|
|
406
|
+
if not text:
|
|
407
|
+
return ""
|
|
408
|
+
text = RE_SPECIAL.sub("", text).strip()
|
|
409
|
+
SUPERSCRIPT_MAP = {
|
|
410
|
+
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
|
|
411
|
+
"⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
|
|
412
|
+
"⁺": "+", "⁻": "-", "⁽": "(", "⁾": ")",
|
|
413
|
+
}
|
|
414
|
+
# Strip leading multiple-choice labels
|
|
415
|
+
# E.g., like "c. 3" -> 3, or "b: 2" -> 2
|
|
416
|
+
match = re.match(r"^[A-Za-z]\s*[.:]\s*(.+)$", text)
|
|
417
|
+
if match:
|
|
418
|
+
text = match.group(1)
|
|
419
|
+
# Remove angle-degree markers
|
|
420
|
+
text = re.sub(r"\^\s*\{\s*\\circ\s*\}", "", text) # ^{\circ}
|
|
421
|
+
text = re.sub(r"\^\s*\\circ", "", text) # ^\circ
|
|
422
|
+
text = text.replace("°", "") # Unicode degree
|
|
423
|
+
# unwrap \text{...} if the whole string is wrapped
|
|
424
|
+
match = re.match(r"^\\text\{(?P<x>.+?)\}$", text)
|
|
425
|
+
if match:
|
|
426
|
+
text = match.group("x")
|
|
427
|
+
# strip inline/display math wrappers \( \) \[ \]
|
|
428
|
+
text = re.sub(r"\\\(|\\\)|\\\[|\\\]", "", text)
|
|
429
|
+
# light LaTeX canonicalization
|
|
430
|
+
for pat, rep in LATEX_FIXES:
|
|
431
|
+
text = re.sub(pat, rep, text)
|
|
432
|
+
def convert_superscripts(s, base=None):
|
|
433
|
+
converted = "".join(
|
|
434
|
+
SUPERSCRIPT_MAP[ch] if ch in SUPERSCRIPT_MAP else ch
|
|
435
|
+
for ch in s
|
|
436
|
+
)
|
|
437
|
+
if base is None:
|
|
438
|
+
return converted
|
|
439
|
+
return f"{base}**{converted}"
|
|
440
|
+
# convert unicode superscripts into exponent form (e.g., 2² -> 2**2)m
|
|
441
|
+
text = re.sub(
|
|
442
|
+
r"([0-9A-Za-z\)\]\}])([⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻]+)",
|
|
443
|
+
lambda m: convert_superscripts(m.group(2), base=m.group(1)),
|
|
444
|
+
text,
|
|
445
|
+
)
|
|
446
|
+
text = convert_superscripts(text)
|
|
447
|
+
# numbers/roots
|
|
448
|
+
text = text.replace("\\%", "%").replace("$", "").replace("%", "")
|
|
449
|
+
text = re.sub(
|
|
450
|
+
r"\\sqrt\s*\{([^}]*)\}",
|
|
451
|
+
lambda match: f"sqrt({match.group(1)})",
|
|
452
|
+
text,
|
|
453
|
+
)
|
|
454
|
+
text = re.sub(
|
|
455
|
+
r"\\sqrt\s+([^\\\s{}]+)",
|
|
456
|
+
lambda match: f"sqrt({match.group(1)})",
|
|
457
|
+
text,
|
|
458
|
+
)
|
|
459
|
+
# fractions
|
|
460
|
+
text = re.sub(
|
|
461
|
+
r"\\frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}",
|
|
462
|
+
lambda match: f"({match.group(1)})/({match.group(2)})",
|
|
463
|
+
text,
|
|
464
|
+
)
|
|
465
|
+
text = re.sub(
|
|
466
|
+
r"\\frac\s+([^\s{}]+)\s+([^\s{}]+)",
|
|
467
|
+
lambda match: f"({match.group(1)})/({match.group(2)})",
|
|
468
|
+
text,
|
|
469
|
+
)
|
|
470
|
+
# exponent and mixed numbers
|
|
471
|
+
text = text.replace("^", "**")
|
|
472
|
+
text = re.sub(
|
|
473
|
+
r"(?<=\d)\s+(\d+/\d+)",
|
|
474
|
+
lambda match: "+" + match.group(1),
|
|
475
|
+
text,
|
|
476
|
+
)
|
|
477
|
+
# 1,234 -> 1234
|
|
478
|
+
text = re.sub(
|
|
479
|
+
r"(?<=\d),(?=\d\d\d(\D|$))",
|
|
480
|
+
"",
|
|
481
|
+
text,
|
|
482
|
+
)
|
|
483
|
+
return text.replace("{", "").replace("}", "").strip().lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|