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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simonbb
3
- Version: 0.0.7
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"
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 os.path.isfile(path):
42
- filename = os.path.basename(path)
43
- print(filename)
44
- ext = filename.lower()
45
- if ext.endswith('.pdf'):
46
- documents = PyPDFLoader(path).load()
47
- elif ext.endswith(('.html', '.ejs')):
48
- documents = UnstructuredHTMLLoader(path).load()
49
- else:
50
- print(f"Unsupported file type: {filename}")
51
- return None, 0, 0
52
- success_cnt = 1
53
- page_cnt = len(documents)
54
- docs.extend(documents)
55
- elif os.path.isdir(path):
56
- for filename in os.listdir(path):
57
- file = os.path.join(path, filename)
58
- if not os.path.isfile(file):
59
- continue
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
- continue
68
- success_cnt += 1
69
- page_cnt += len(documents)
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