QuizGenerator 0.1.3__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
QuizGenerator/generate.py CHANGED
@@ -25,6 +25,8 @@ def parse_args():
25
25
  default=os.path.join(Path.home(), '.env'),
26
26
  help="Path to .env file specifying canvas details"
27
27
  )
28
+
29
+ parser.add_argument("--debug", action="store_true", help="Set logging level to debug")
28
30
 
29
31
  parser.add_argument("--quiz_yaml", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "example_files/exam_generation.yaml"))
30
32
  parser.add_argument("--seed", type=int, default=None,
@@ -211,6 +213,21 @@ def main():
211
213
 
212
214
  # Load environment variables
213
215
  load_dotenv(args.env)
216
+
217
+ if args.debug:
218
+ # Set root logger to DEBUG
219
+ logging.getLogger().setLevel(logging.DEBUG)
220
+
221
+ # Set all handlers to DEBUG level
222
+ for handler in logging.getLogger().handlers:
223
+ handler.setLevel(logging.DEBUG)
224
+
225
+ # Set named loggers to DEBUG
226
+ for logger_name in ['QuizGenerator', 'lms_interface', '__main__']:
227
+ logger = logging.getLogger(logger_name)
228
+ logger.setLevel(logging.DEBUG)
229
+ for handler in logger.handlers:
230
+ handler.setLevel(logging.DEBUG)
214
231
 
215
232
  if args.command == "TEST":
216
233
  test()
@@ -233,4 +250,4 @@ def main():
233
250
 
234
251
 
235
252
  if __name__ == "__main__":
236
- main()
253
+ main()
QuizGenerator/misc.py CHANGED
@@ -6,16 +6,21 @@ import enum
6
6
  import itertools
7
7
  import logging
8
8
  import math
9
- from typing import List, Dict, Tuple
9
+ import numpy as np
10
+ from typing import List, Dict, Tuple, Any
10
11
 
11
12
  import fractions
12
13
 
14
+ from QuizGenerator.contentast import ContentAST
15
+
13
16
  log = logging.getLogger(__name__)
14
17
 
15
18
 
16
- class OutputFormat(enum.Enum):
17
- LATEX = enum.auto(),
18
- CANVAS = enum.auto()
19
+ def fix_negative_zero(value):
20
+ """Convert -0.0 to 0.0 to avoid confusing display."""
21
+ if isinstance(value, (int, float)):
22
+ return 0.0 if value == 0 else value
23
+ return value
19
24
 
20
25
 
21
26
  class Answer:
@@ -23,11 +28,12 @@ class Answer:
23
28
 
24
29
  class AnswerKind(enum.Enum):
25
30
  BLANK = "fill_in_multiple_blanks_question"
26
- MULTIPLE_ANSWER = "multiple_answers_question" # todo: have baffles?
31
+ MULTIPLE_ANSWER = "multiple_answers_question"
27
32
  ESSAY = "essay_question"
28
33
  MULTIPLE_DROPDOWN = "multiple_dropdowns_question"
34
+ NUMERICAL_QUESTION = "numerical_question" # note: these can only be single answers as far as I can tell
29
35
 
30
- class VariableKind(enum.Enum): # todo: use these for generate variations?
36
+ class VariableKind(enum.Enum):
31
37
  STR = enum.auto()
32
38
  INT = enum.auto()
33
39
  FLOAT = enum.auto()
@@ -37,6 +43,7 @@ class Answer:
37
43
  AUTOFLOAT = enum.auto()
38
44
  LIST = enum.auto()
39
45
  VECTOR = enum.auto()
46
+ MATRIX = enum.auto()
40
47
 
41
48
 
42
49
  def __init__(
@@ -66,7 +73,7 @@ class Answer:
66
73
  self.baffles = baffles
67
74
  self.pdf_only = pdf_only
68
75
 
69
- def get_for_canvas(self) -> List[Dict]:
76
+ def get_for_canvas(self, single_answer=False) -> List[Dict]:
70
77
  # If this answer is marked as PDF-only, don't send it to Canvas
71
78
  if self.pdf_only:
72
79
  return []
@@ -131,18 +138,29 @@ class Answer:
131
138
  Answer.VariableKind.FLOAT,
132
139
  Answer.VariableKind.INT
133
140
  ]:
134
- # Use the accepted_strings helper with settings that match the original AUTOFLOAT behavior
135
- answer_strings = self.__class__.accepted_strings(
136
- self.value,
137
- allow_integer=True,
138
- allow_simple_fraction=True,
139
- max_denominator=3*4*5, # For process questions, these are the numbers of jobs we'd have
140
- allow_mixed=True,
141
- include_spaces=False,
142
- include_fixed_even_if_integer=True
143
- )
144
-
145
- canvas_answers = [
141
+ if single_answer:
142
+ canvas_answers = [
143
+ {
144
+ "numerical_answer_type": "exact_answer",
145
+ "answer_text": round(self.value, self.DEFAULT_ROUNDING_DIGITS),
146
+ "answer_exact": round(self.value, self.DEFAULT_ROUNDING_DIGITS),
147
+ "answer_error_margin": 0.1,
148
+ "answer_weight": 100 if self.correct else 0,
149
+ }
150
+ ]
151
+ else:
152
+ # Use the accepted_strings helper with settings that match the original AUTOFLOAT behavior
153
+ answer_strings = self.__class__.accepted_strings(
154
+ self.value,
155
+ allow_integer=True,
156
+ allow_simple_fraction=True,
157
+ max_denominator=3*4*5, # For process questions, these are the numbers of jobs we'd have
158
+ allow_mixed=True,
159
+ include_spaces=False,
160
+ include_fixed_even_if_integer=True
161
+ )
162
+
163
+ canvas_answers = [
146
164
  {
147
165
  "blank_id": self.key,
148
166
  "answer_text": answer_string,
@@ -188,6 +206,7 @@ class Answer:
188
206
  }
189
207
  for possible_state in [self.value] #itertools.permutations(self.value)
190
208
  ]
209
+
191
210
  else:
192
211
  # For string answers, check if value is a list of acceptable alternatives
193
212
  if isinstance(self.value, list):
@@ -244,13 +263,16 @@ class Answer:
244
263
 
245
264
  elif self.variable_kind == Answer.VariableKind.AUTOFLOAT:
246
265
  # Round to default precision for readability
247
- return f"{round(self.value, self.DEFAULT_ROUNDING_DIGITS)}"
266
+ rounded = round(self.value, self.DEFAULT_ROUNDING_DIGITS)
267
+ return f"{fix_negative_zero(rounded)}"
248
268
 
249
269
  elif self.variable_kind == Answer.VariableKind.FLOAT:
250
270
  # Round to default precision
251
271
  if isinstance(self.value, (list, tuple)):
252
- return f"{round(self.value[0], self.DEFAULT_ROUNDING_DIGITS)}"
253
- return f"{round(self.value, self.DEFAULT_ROUNDING_DIGITS)}"
272
+ rounded = round(self.value[0], self.DEFAULT_ROUNDING_DIGITS)
273
+ return f"{fix_negative_zero(rounded)}"
274
+ rounded = round(self.value, self.DEFAULT_ROUNDING_DIGITS)
275
+ return f"{fix_negative_zero(rounded)}"
254
276
 
255
277
  elif self.variable_kind == Answer.VariableKind.INT:
256
278
  return str(int(self.value))
@@ -260,12 +282,17 @@ class Answer:
260
282
 
261
283
  elif self.variable_kind == Answer.VariableKind.VECTOR:
262
284
  # Format as comma-separated rounded values
263
- return ", ".join(str(round(v, self.DEFAULT_ROUNDING_DIGITS)) for v in self.value)
285
+ return ", ".join(str(fix_negative_zero(round(v, self.DEFAULT_ROUNDING_DIGITS))) for v in self.value)
264
286
 
265
287
  else:
266
288
  # Default: use display or value
267
289
  return str(self.display if hasattr(self, 'display') else self.value)
268
290
 
291
+ def get_ast_element(self, label=None):
292
+ from QuizGenerator.contentast import ContentAST
293
+
294
+ return ContentAST.Answer(answer=self, label=label) # todo fix label
295
+
269
296
  # Factory methods for common answer types
270
297
  @classmethod
271
298
  def binary_hex(cls, key: str, value: int, length: int = None, **kwargs) -> 'Answer':
@@ -392,9 +419,18 @@ class Answer:
392
419
  **kwargs
393
420
  )
394
421
 
422
+ @classmethod
423
+ def matrix(cls, key: str, value: np.array|List, **kwargs ):
424
+ return MatrixAnswer(
425
+ key=key,
426
+ value=value,
427
+ variable_kind=cls.VariableKind.MATRIX
428
+ )
429
+
395
430
  @staticmethod
396
431
  def _to_fraction(x):
397
432
  """Convert int/float/decimal.Decimal/fractions.Fraction/str('a/b' or decimal) to fractions.Fraction exactly."""
433
+ log.debug(f"x: {x} {x.__class__}")
398
434
  if isinstance(x, fractions.Fraction):
399
435
  return x
400
436
  if isinstance(x, int):
@@ -477,4 +513,67 @@ class Answer:
477
513
  outs.add(f"{sign}{whole} {rem}/{b}")
478
514
 
479
515
  return sorted(outs, key=lambda s: (len(s), s))
516
+
480
517
 
518
+ class MatrixAnswer(Answer):
519
+ def get_for_canvas(self, single_answer=False) -> List[Dict]:
520
+ canvas_answers = []
521
+
522
+ """
523
+ The core idea is that we will walk through and generate each one for X_i,j
524
+
525
+ The big remaining question is how we will get all these names to the outside world.
526
+ It might have to be a pretty big challenge, or rather re-write.
527
+ """
528
+
529
+ # The core idea is that we will be walking through and generating a per-index set of answers.
530
+ # Boy will this get messy. Poor canvas.
531
+ for i, j in np.ndindex(self.value.shape):
532
+ entry_strings = self.__class__.accepted_strings(
533
+ self.value[i,j],
534
+ allow_integer=True,
535
+ allow_simple_fraction=True,
536
+ max_denominator=3 * 4 * 5,
537
+ allow_mixed=True,
538
+ include_spaces=False,
539
+ include_fixed_even_if_integer=True
540
+ )
541
+ canvas_answers.extend(
542
+ [
543
+ {
544
+ "blank_id": f"{self.key}_{i}_{j}", # Give each an index associated with it so we can track it
545
+ "answer_text": answer_string,
546
+ "answer_weight": 100 if self.correct else 0,
547
+ }
548
+ for answer_string in entry_strings
549
+ ]
550
+ )
551
+
552
+ return canvas_answers
553
+
554
+ def get_ast_element(self, label=None):
555
+ from QuizGenerator.contentast import ContentAST
556
+
557
+ log.debug(f"self.value: {self.value}")
558
+
559
+ data = [
560
+ [
561
+ ContentAST.Answer(
562
+ Answer.float_value(
563
+ key=f"{self.key}_{i}_{j}",
564
+ value=self.value[i,j]
565
+ )
566
+ )
567
+ for i in range(self.value.shape[0])
568
+ ]
569
+ for j in range(self.value.shape[1])
570
+ ]
571
+ table = ContentAST.Table(data)
572
+
573
+ if label is not None:
574
+ return ContentAST.Container([
575
+ ContentAST.Text(f"{label} = "),
576
+ table
577
+ ])
578
+ else:
579
+ return table
QuizGenerator/mixins.py CHANGED
@@ -32,8 +32,17 @@ class TableQuestionMixin:
32
32
  Returns:
33
33
  ContentAST.Table with the information formatted
34
34
  """
35
+ # Don't convert ContentAST elements to strings - let them render properly
36
+ table_data = []
37
+ for key, value in info_dict.items():
38
+ # Keep ContentAST elements as-is, convert others to strings
39
+ if isinstance(value, ContentAST.Element):
40
+ table_data.append([key, value])
41
+ else:
42
+ table_data.append([key, str(value)])
43
+
35
44
  return ContentAST.Table(
36
- data=[[key, str(value)] for key, value in info_dict.items()],
45
+ data=table_data,
37
46
  transpose=transpose
38
47
  )
39
48
 
@@ -190,6 +190,8 @@ class CachingQuestion(MemoryQuestion, RegenerableChoiceMixin, TableQuestionMixin
190
190
  self.num_elements = self.config_params.get("num_elements", 5)
191
191
  self.cache_size = self.config_params.get("cache_size", 3)
192
192
  self.num_requests = self.config_params.get("num_requests", 10)
193
+
194
+ self.hit_rate = 0. # placeholder
193
195
 
194
196
  def refresh(self, previous: Optional[CachingQuestion] = None, *args, hard_refresh: bool = False, **kwargs):
195
197
  # Call parent refresh which seeds RNG and calls is_interesting()
@@ -200,7 +202,7 @@ class CachingQuestion(MemoryQuestion, RegenerableChoiceMixin, TableQuestionMixin
200
202
  self.cache_policy = self.get_choice('policy', self.Kind)
201
203
 
202
204
  self.requests = (
203
- list(range(self.cache_size)) # Prime the cache with the compulsory misses
205
+ list(range(self.cache_size)) # Prime the cache with the capacity misses
204
206
  + self.rng.choices(
205
207
  population=list(range(self.cache_size - 1)), k=1
206
208
  ) # Add in one request to an earlier that will differentiate clearly between FIFO and LRU
@@ -274,7 +276,7 @@ class CachingQuestion(MemoryQuestion, RegenerableChoiceMixin, TableQuestionMixin
274
276
  hit_rate_block = ContentAST.AnswerBlock(
275
277
  ContentAST.Answer(
276
278
  answer=self.answers["answer__hit_rate"],
277
- label=f"Hit rate, excluding compulsory misses. If appropriate, round to {Answer.DEFAULT_ROUNDING_DIGITS} decimal digits.",
279
+ label=f"Hit rate, excluding capacity misses. If appropriate, round to {Answer.DEFAULT_ROUNDING_DIGITS} decimal digits.",
278
280
  unit="%"
279
281
  )
280
282
  )
@@ -324,7 +326,7 @@ class CachingQuestion(MemoryQuestion, RegenerableChoiceMixin, TableQuestionMixin
324
326
  "To calculate the hit rate we calculate the percentage of requests "
325
327
  "that were cache hits out of the total number of requests. "
326
328
  f"In this case we are counting only all but {self.cache_size} requests, "
327
- f"since we are excluding compulsory misses."
329
+ f"since we are excluding capacity misses."
328
330
  ]
329
331
  )
330
332
  )
@@ -636,7 +638,7 @@ class Segmentation(MemoryAccessQuestion, TableQuestionMixin, BodyTemplatesMixin)
636
638
  f"Since we are in the {self.segment} segment, "
637
639
  f"we see from our table that our bounds are {self.bounds[self.segment]}. "
638
640
  f"Remember that our check for our {self.segment} segment is: ",
639
- f"`if (offset > bounds({self.segment})) : INVALID`",
641
+ f"`if (offset >= bounds({self.segment})) : INVALID`",
640
642
  "which becomes"
641
643
  f"`if ({self.offset:0b} > {self.bounds[self.segment]:0b}) : INVALID`"
642
644
  ]
@@ -15,7 +15,6 @@ from typing import List
15
15
 
16
16
  import matplotlib.pyplot as plt
17
17
 
18
- from QuizGenerator.misc import OutputFormat
19
18
  from QuizGenerator.contentast import ContentAST
20
19
  from QuizGenerator.question import Question, Answer, QuestionRegistry, RegenerableChoiceMixin
21
20
  from QuizGenerator.mixins import TableQuestionMixin, BodyTemplatesMixin
@@ -380,7 +379,7 @@ class SchedulingQuestion(ProcessQuestion, RegenerableChoiceMixin, TableQuestionM
380
379
  # Return whether this workload is interesting
381
380
  return self.is_interesting()
382
381
 
383
- def get_body(self, output_format: OutputFormat|None = None, *args, **kwargs) -> ContentAST.Section:
382
+ def get_body(self, *args, **kwargs) -> ContentAST.Section:
384
383
  # Create table data for scheduling results
385
384
  table_rows = []
386
385
  for job_id in sorted(self.job_stats.keys()):
@@ -0,0 +1,192 @@
1
+ import abc
2
+ import logging
3
+ import math
4
+ import keras
5
+ import numpy as np
6
+
7
+ from QuizGenerator.misc import MatrixAnswer
8
+ from QuizGenerator.question import Question, QuestionRegistry, Answer
9
+ from QuizGenerator.contentast import ContentAST
10
+ from QuizGenerator.constants import MathRanges
11
+ from QuizGenerator.mixins import TableQuestionMixin
12
+
13
+ from .matrices import MatrixQuestion
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @QuestionRegistry.register("cst463.attention.forward-pass")
19
+ class AttentionForwardPass(MatrixQuestion, TableQuestionMixin):
20
+
21
+ @staticmethod
22
+ def simple_attention(Q, K, V):
23
+ """
24
+ Q: (seq_len, d_k) - queries
25
+ K: (seq_len, d_k) - keys
26
+ V: (seq_len, d_v) - values
27
+
28
+ Returns: (seq_len, d_v) - attended output
29
+ """
30
+ d_k = Q.shape[1]
31
+
32
+ # Compute attention scores
33
+ scores = Q @ K.T / np.sqrt(d_k)
34
+
35
+ # Softmax to get weights
36
+ attention_weights = np.exp(scores) / np.exp(scores).sum(axis=1, keepdims=True)
37
+
38
+ # Weighted sum of values
39
+ output = attention_weights @ V
40
+
41
+ return output, attention_weights
42
+
43
+ def refresh(self, *args, **kwargs):
44
+ super().refresh(*args, **kwargs)
45
+
46
+ seq_len = kwargs.get("seq_len", 3)
47
+ d_k = kwargs.get("key_dimension", 1) # key/query dimension
48
+ d_v = kwargs.get("value_dimension", 1) # value dimension
49
+
50
+ # Small integer matrices
51
+ self.Q = self.rng.randint(0, 3, size=(seq_len, d_k))
52
+ self.K = self.rng.randint(0, 3, size=(seq_len, d_k))
53
+ self.V = self.rng.randint(0, 3, size=(seq_len, d_v))
54
+
55
+ self.Q = self.get_rounded_matrix((seq_len, d_k), 0, 3)
56
+ self.K = self.get_rounded_matrix((seq_len, d_k), 0, 3)
57
+ self.V = self.get_rounded_matrix((seq_len, d_v), 0, 3)
58
+
59
+ self.output, self.weights = self.simple_attention(self.Q, self.K, self.V)
60
+
61
+ ## Answers:
62
+ # Q, K, V, output, weights
63
+
64
+ self.answers["weights"] = MatrixAnswer("weights", self.output)
65
+ self.answers["output"] = MatrixAnswer("output", self.output)
66
+
67
+ return True
68
+
69
+ def get_body(self, **kwargs) -> ContentAST.Section:
70
+ body = ContentAST.Section()
71
+
72
+ body.add_element(
73
+ ContentAST.Text("Given the below information about a self attention layer, please calculate the output sequence.")
74
+ )
75
+ body.add_element(
76
+ self.create_info_table(
77
+ {
78
+ "Q": ContentAST.Matrix(self.Q),
79
+ "K": ContentAST.Matrix(self.K),
80
+ "V": ContentAST.Matrix(self.V),
81
+ }
82
+ )
83
+ )
84
+
85
+ body.add_elements([
86
+ ContentAST.LineBreak(),
87
+ self.answers["weights"].get_ast_element(label=f"Weights"),
88
+ ContentAST.LineBreak(),
89
+ self.answers["output"].get_ast_element(label=f"Output"),
90
+ ])
91
+
92
+ return body
93
+
94
+ def get_explanation(self, **kwargs) -> ContentAST.Section:
95
+ explanation = ContentAST.Section()
96
+ digits = Answer.DEFAULT_ROUNDING_DIGITS
97
+
98
+ explanation.add_element(
99
+ ContentAST.Paragraph([
100
+ "Self-attention uses scaled dot-product attention to compute a weighted combination of values based on query-key similarity."
101
+ ])
102
+ )
103
+
104
+ # Step 1: Compute attention scores
105
+ explanation.add_element(
106
+ ContentAST.Paragraph([
107
+ ContentAST.Text("Step 1: Compute attention scores", emphasis=True)
108
+ ])
109
+ )
110
+
111
+ d_k = self.Q.shape[1]
112
+ explanation.add_element(
113
+ ContentAST.Equation(f"\\text{{scores}} = \\frac{{Q K^T}}{{\\sqrt{{d_k}}}} = \\frac{{Q K^T}}{{\\sqrt{{{d_k}}}}}")
114
+ )
115
+
116
+ scores = self.Q @ self.K.T / np.sqrt(d_k)
117
+
118
+ explanation.add_element(
119
+ ContentAST.Paragraph([
120
+ "Raw scores (scaling by ",
121
+ ContentAST.Equation(f'\\sqrt{{{d_k}}}', inline=True),
122
+ " prevents extremely large values):"
123
+ ])
124
+ )
125
+ explanation.add_element(ContentAST.Matrix(np.round(scores, digits)))
126
+
127
+ # Step 2: Apply softmax
128
+ explanation.add_element(
129
+ ContentAST.Paragraph([
130
+ ContentAST.Text("Step 2: Apply softmax to get attention weights", emphasis=True)
131
+ ])
132
+ )
133
+
134
+ explanation.add_element(
135
+ ContentAST.Equation(r"\alpha_{ij} = \frac{\exp(\text{score}_{ij})}{\sum_k \exp(\text{score}_{ik})}")
136
+ )
137
+
138
+ # Show ONE example row
139
+ explanation.add_element(
140
+ ContentAST.Paragraph([
141
+ "Example: Row 0 softmax computation"
142
+ ])
143
+ )
144
+
145
+ row_scores = scores[0]
146
+ exp_scores = np.exp(row_scores)
147
+ sum_exp = exp_scores.sum()
148
+ weights_row = exp_scores / sum_exp
149
+
150
+ exp_terms = " + ".join([f"e^{{{s:.{digits}f}}}" for s in row_scores])
151
+
152
+ explanation.add_element(
153
+ ContentAST.Paragraph([
154
+ f"Denominator = {exp_terms} = {sum_exp:.{digits}f}"
155
+ ])
156
+ )
157
+
158
+ # Format array with proper rounding
159
+ weights_str = "[" + ", ".join([f"{w:.{digits}f}" for w in weights_row]) + "]"
160
+ explanation.add_element(
161
+ ContentAST.Paragraph([
162
+ f"Resulting weights: {weights_str}"
163
+ ])
164
+ )
165
+
166
+ explanation.add_element(
167
+ ContentAST.Paragraph([
168
+ "Complete attention weight matrix:"
169
+ ])
170
+ )
171
+ explanation.add_element(ContentAST.Matrix(np.round(self.weights, digits)))
172
+
173
+ # Step 3: Weighted sum of values
174
+ explanation.add_element(
175
+ ContentAST.Paragraph([
176
+ ContentAST.Text("Step 3: Compute weighted sum of values", emphasis=True)
177
+ ])
178
+ )
179
+
180
+ explanation.add_element(
181
+ ContentAST.Equation(r"\text{output} = \text{weights} \times V")
182
+ )
183
+
184
+ explanation.add_element(
185
+ ContentAST.Paragraph([
186
+ "Final output:"
187
+ ])
188
+ )
189
+ explanation.add_element(ContentAST.Matrix(np.round(self.output, digits)))
190
+
191
+ return explanation
192
+